Merge remote-tracking branch 'origin/main' into feat/spell-check

# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
bflatastic 2026-10-11 10:22:24 +07:00
commit e1cd613b1d
99 changed files with 7284 additions and 1106 deletions

18
Cargo.lock generated
View File

@ -1608,6 +1608,24 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "figcraft-server"
version = "0.3.1"
dependencies = [
"crc32fast",
"env_logger",
"figcraft-collab",
"figcraft-collab-net",
"figcraft-doc",
"figcraft-engine",
"log",
"serde",
"serde_json",
"sha2",
"toml",
"tungstenite",
]
[[package]]
name = "figcraft-svg"
version = "0.3.1"

View File

@ -88,7 +88,7 @@
<tr>
<td width="50%" valign="top">
<h3>Vectors without compromise</h3>
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.
</td>
<td width="50%" valign="top">
<h3>Paints and effects</h3>
@ -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

View File

@ -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) |

View File

@ -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<NodeId> = (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<ExitCode> {

View File

@ -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"] }

View File

@ -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<Cli, String> {
// 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<Endpoint, String> {
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<String, String> {
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<String, String> {
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<SocketAddr, String> {
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<AdminReply, String> {
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<String, String> {
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<String, String> {
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::<String>()) }
}
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")
}

View File

@ -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<String> {
s.iter().map(|a| a.to_string()).collect()
}
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
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");
}

View File

@ -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<String>,
/// 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<Config, String> {
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<SocketAddr, String> {
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<Option<Endpoint>, String> {
let Some(url) = self.public_url.as_deref().map(str::trim).filter(|u| !u.is_empty()) else { return Ok(None) };
let (e, query) = Endpoint::parse(url).map_err(|e| format!("`public_url`: {e}"))?;
if !query.is_empty() {
return Err("`public_url` can't have a query (no keys or tokens in it)".into());
}
Ok(Some(e))
}
/// Check everything (before starting).
pub fn check(&self) -> Result<(), String> {
self.addr()?;
self.public()?;
for (name, v) in [
("max_files", self.max_files as u64),
("max_file_mb", self.max_file_mb),
("max_links", self.max_links as u64),
("max_connections", self.max_connections as u64),
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
] {
if v == 0 {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
Ok(())
}
/// The store's part.
pub fn store(&self) -> StoreConfig {
let mib = |n: u64| n.saturating_mul(1 << 20);
StoreConfig {
max_files: self.max_files,
max_file_bytes: mib(self.max_file_mb),
max_links: self.max_links,
compact_after: self.compact_after,
compact_bytes: mib(self.compact_mb),
core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() },
}
}
/// The transport's part.
pub fn net(&self) -> NetLimits {
NetLimits { max_connections: self.max_connections, ..NetLimits::default() }
}
}

View File

@ -0,0 +1,200 @@
//! The server's files on disk and reading them back without trusting them: atomic writes (a
//! temporary file beside the target, flushed, then renamed over it), the snapshot format and the
//! append-only log format.
//!
//! - `snapshot.bin`: `FCSN`, a format byte, the generation (u64 LE), a CRC-32 of the rest (u32 LE),
//! then the session state ([`SessionState`] in the wire encoding).
//! - `log.bin`: `FCLG`, a format byte, the generation of the snapshot it continues, then records:
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
//!
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use figcraft_collab::message;
use figcraft_collab::{Journal, SessionState};
pub const SNAPSHOT: &str = "snapshot.bin";
pub const LOG: &str = "log.bin";
pub const META: &str = "meta.json";
pub const LINKS: &str = "links.json";
const SNAP_MAGIC: &[u8; 4] = b"FCSN";
const LOG_MAGIC: &[u8; 4] = b"FCLG";
const FORMAT: u8 = 1;
const SNAP_HEADER: usize = 4 + 1 + 8 + 4;
pub const LOG_HEADER: usize = 4 + 1 + 8;
const RECORD_HEADER: usize = 8;
/// `path` with `.tmp` added: where its next version is written first.
pub fn temp_of(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let tmp = temp_of(path);
{
let mut f = File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
sync_dir(path.parent());
Ok(())
}
/// Make a rename in `dir` durable (directories can't be opened for that on Windows).
pub fn sync_dir(dir: Option<&Path>) {
#[cfg(unix)]
if let Some(d) = dir
&& let Ok(f) = File::open(d)
{
let _ = f.sync_all();
}
#[cfg(not(unix))]
let _ = dir;
}
/// Read a file of at most `max` bytes (a longer one is refused without being read).
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?;
let len = f.metadata().map_err(|e| e.to_string())?.len();
if len > max {
return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len));
}
let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
f.take(max).read_to_end(&mut bytes).map_err(|e| format!("can't read {}: {e}", name(path)))?;
Ok(bytes)
}
fn name(path: &Path) -> String {
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}
fn crc(bytes: &[u8]) -> u32 {
crc32fast::hash(bytes)
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes)
}
fn u64_at(b: &[u8], at: usize) -> Option<u64> {
b.get(at..at + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes)
}
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
let body = message::encode(state).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
Ok(out)
}
/// A snapshot's generation and body, when its header and checksum are right.
fn snapshot_body(bytes: &[u8]) -> Result<(u64, &[u8]), String> {
if bytes.get(..4) != Some(SNAP_MAGIC.as_slice()) {
return Err("the snapshot isn't one (wrong header)".into());
}
if bytes.get(4) != Some(&FORMAT) {
return Err("the snapshot is in a format this version doesn't read".into());
}
let (Some(generation), Some(sum), Some(body)) = (u64_at(bytes, 5), u32_at(bytes, 13), bytes.get(SNAP_HEADER..)) else {
return Err("the snapshot is cut short".into());
};
if crc(body) != sum {
return Err("the snapshot is damaged (checksum mismatch)".into());
}
Ok((generation, body))
}
/// A snapshot's generation, after checking its header and checksum (no decoding).
pub fn check_snapshot(bytes: &[u8]) -> Result<u64, String> {
snapshot_body(bytes).map(|(g, _)| g)
}
/// A snapshot's generation and session state.
pub fn decode_snapshot(bytes: &[u8]) -> Result<(u64, SessionState), String> {
let (generation, body) = snapshot_body(bytes)?;
let state = message::decode::<SessionState>(body, body.len()).map_err(|e| format!("the snapshot can't be read: {e}"))?;
Ok((generation, state))
}
/// An empty log continuing snapshot `generation`.
pub fn log_header(generation: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(LOG_HEADER);
out.extend_from_slice(LOG_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out
}
/// One log record.
pub fn record(entry: &Journal) -> Result<Vec<u8>, String> {
let body = message::encode(entry).map_err(|e| e.to_string())?;
let len = u32::try_from(body.len()).map_err(|_| "a journal entry over 4 GiB".to_string())?;
let mut out = Vec::with_capacity(RECORD_HEADER + body.len());
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
Ok(out)
}
/// What a log holds.
#[derive(Debug)]
pub struct LogRead {
pub generation: u64,
pub entries: Vec<Journal>,
/// Why it ends before the end of the file (a damaged or cut-short record), if it does.
pub damaged: Option<String>,
}
/// Read a log: its generation and the entries up to the first damaged record. Only a header that
/// isn't a log's is an error.
pub fn read_log(bytes: &[u8]) -> Result<LogRead, String> {
if bytes.get(..4) != Some(LOG_MAGIC.as_slice()) {
return Err("the log isn't one (wrong header)".into());
}
if bytes.get(4) != Some(&FORMAT) {
return Err("the log is in a format this version doesn't read".into());
}
let generation = u64_at(bytes, 5).ok_or("the log is cut short")?;
let mut at = LOG_HEADER;
let mut entries = Vec::new();
let mut damaged = None;
while at < bytes.len() {
let (Some(len), Some(sum)) = (u32_at(bytes, at), u32_at(bytes, at + 4)) else {
damaged = Some(format!("a record cut short at byte {at}"));
break;
};
let start = at + RECORD_HEADER;
let Some(body) = usize::try_from(len).ok().and_then(|n| bytes.get(start..start.checked_add(n)?)) else {
damaged = Some(format!("a record cut short at byte {at}"));
break;
};
if crc(body) != sum {
damaged = Some(format!("a damaged record at byte {at}"));
break;
}
match message::decode::<Journal>(body, body.len()) {
Ok(e) => entries.push(e),
Err(e) => {
damaged = Some(format!("an unreadable record at byte {at} ({e})"));
break;
}
}
at = start + body.len();
}
Ok(LogRead { generation, entries, damaged })
}

View File

@ -0,0 +1,80 @@
//! `figcraft-server`: FigCraft's standalone collaboration server (milestone M9, step 5 of
//! `docs/collab-protocol.md`; running it: `docs/server.md`).
//!
//! It keeps many files in a data directory ([`store`]), each with share links that let people
//! edit or only view, and serves their live sessions through the same hub the desktop app hosts
//! with ([`figcraft_collab_net::hub`]). Files persist as a snapshot plus an append-only log of what
//! changed ([`disk`]), compacted into a new snapshot from time to time, so a restart loses nothing
//! that was confirmed and clients resume where they were. There are no user accounts yet: share
//! links are the access, and the server key manages files.
pub mod cli;
pub mod config;
pub mod disk;
pub mod store;
use std::net::{SocketAddr, TcpListener};
use figcraft_collab_net::hub::Hosting;
use crate::config::Config;
use crate::store::Store;
/// A running server. Dropping it stops it: open files are saved, everyone is told the server is
/// restarting (their apps reconnect once it is back).
pub struct Running {
hosting: Hosting,
}
impl Running {
/// Where it listens.
pub fn addr(&self) -> SocketAddr {
self.hosting.addr()
}
/// Open connections.
pub fn connections(&self) -> usize {
self.hosting.connections()
}
/// Stop (see the type's docs).
pub fn stop(&mut self) {
self.hosting.stop();
}
}
/// Start serving `cfg.data` on `cfg.listen`. Files that can't be read are skipped and logged.
pub fn start(cfg: &Config) -> Result<Running, String> {
cfg.check()?;
let store = Store::open(&cfg.data, cfg.store())?;
for p in store.problems() {
log::warn!("{p}");
}
let files = store.len();
let addr = cfg.addr()?;
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
let addr = listener.local_addr().map_err(|e| e.to_string())?;
cli::write_running(&cfg.data, addr)?;
let public = cli::public_endpoint(cfg, addr)?;
let hosting = Hosting::serve(listener, store, cfg.net()).map_err(|e| format!("can't start serving: {e}"))?;
log::info!(
"figcraft-server {}: {files} file{} in {}; listening on {addr}; links name {}",
env!("CARGO_PKG_VERSION"),
if files == 1 { "" } else { "s" },
cfg.data.display(),
public.url()
);
if addr.ip().is_loopback() {
log::info!("only this machine can connect: listen on 0.0.0.0:{} (or behind a reverse proxy) to share", addr.port());
}
Ok(Running { hosting })
}
#[cfg(test)]
mod cli_tests;
#[cfg(test)]
mod server_tests;
#[cfg(test)]
mod store_tests;
#[cfg(test)]
mod test_util;

View File

@ -0,0 +1,40 @@
//! `figcraft-server`: see `docs/server.md`, or run it with `--help`.
use std::process::ExitCode;
use figcraft_server::cli::{self, Command};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let cli = match cli::parse(&args) {
Ok(c) => c,
Err(e) => {
eprintln!("figcraft-server: {e}\n(figcraft-server --help lists what it takes)");
return ExitCode::from(2);
}
};
if cli.command != Command::Serve {
return match cli::run(&cli) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("figcraft-server: {e}");
ExitCode::FAILURE
}
};
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).target(env_logger::Target::Stdout).init();
match figcraft_server::start(&cli.config) {
// Serves until the process is stopped: what was confirmed is on disk already, and clients
// reconnect once it runs again.
Ok(_running) => loop {
std::thread::park();
},
Err(e) => {
log::error!("{e}");
ExitCode::FAILURE
}
}
}

View File

@ -0,0 +1,280 @@
//! The server end to end over loopback, in-process with a temporary data directory: an app
//! publishes its file and syncs through the server, others join with the edit link and the view
//! link, edits converge and the viewer's are refused (by its app, and by the server for a client
//! that skips the check), the server restarts mid-session and everyone resumes without losing a
//! confirmed edit, revoked links stop working at once, and fuzzed frames don't hurt it.
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::time::Duration;
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, ServerMsg};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Client, VIEW_ONLY};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::{Published, Status};
use serde_json::{Value, json};
use tungstenite::{Bytes, Message, WebSocket};
use crate::test_util::{TempDir, config, pump, same, session};
fn key(dir: &Path) -> String {
std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string()
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn live(s: &Session) -> bool {
s.live.status == Status::Live && s.live.syncing()
}
/// `a` publishes its open file to the server at `addr`; the links.
fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
a.live.published.clone().unwrap()
}
fn join(link: &str, name: &str) -> Session {
let mut s = session(name);
s.execute("collab.join", &json!({"link": link})).unwrap();
s
}
/// A bare client: hello, then what it's told.
struct Raw(WebSocket<TcpStream>);
impl Raw {
fn connect(addr: SocketAddr) -> Raw {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0)
}
fn send(&mut self, m: &ClientMsg) {
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
}
fn next(&mut self) -> ServerMsg {
loop {
if let Message::Binary(b) = self.0.read().unwrap() {
return message::decode(&b, usize::MAX).unwrap();
}
}
}
}
#[test]
fn publish_then_edit_and_view_together() {
let dir = TempDir::new("share");
let mut server = crate::start(&config(dir.path())).unwrap();
let mut a = session("Ada");
a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 80})).unwrap();
let addr = server.addr();
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir.path()))})).unwrap();
assert_eq!(r["status"], "connecting");
// Nothing changes it until it is on the server.
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
assert!(e.contains("being published"), "{e}");
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
assert_eq!(a.live_summary()["readOnly"], false);
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
assert_ne!(p.edit, p.view);
// Bo joins with the edit link, Cy with the view link.
let mut b = join(&p.edit, "Bo");
let mut c = join(&p.view, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(c.live_summary()["access"], "view");
assert_eq!(c.live_summary()["readOnly"], true);
assert!(same(a.doc().unwrap(), b.doc().unwrap()) && same(a.doc().unwrap(), c.doc().unwrap()));
// Ada and Bo edit; everyone converges, Cy included.
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap());
pump(&mut [&mut a, &mut b, &mut c], "Ada's rectangle at Bo", |s| s[1].doc().unwrap().contains(rect));
b.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Card"}})).unwrap();
pump(&mut [&mut a, &mut b, &mut c], "everyone agreeing", |s| {
let (ad, bd, cd) = (s[0].doc().unwrap(), s[1].doc().unwrap(), s[2].doc().unwrap());
same(ad, bd) && same(ad, cd) && cd.node(rect).is_some_and(|n| n.name == "Card")
});
// Cy can't change anything: commands and gestures are refused before anything is sent.
let before = c.doc().unwrap().clone();
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
assert!(e.contains("View only"), "{e}");
assert!(c.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Mine"}})).is_err());
assert!(c.begin_interaction("Move").is_err());
assert!(same(&before, c.doc().unwrap()));
// Cy is still seen: presence goes both ways.
let cy = c.live.info.as_ref().unwrap().me;
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
// A client that skips the app's check is refused by the server.
let view = Invite::parse(&p.view).unwrap();
let mut raw = Raw::connect(addr);
raw.send(&Client::hello(&view.file, &view.token, "Raw", ""));
let welcome = loop {
if let w @ ServerMsg::Welcome { .. } = raw.next() {
break w;
}
};
let mut client = Client::join(welcome).unwrap();
assert_eq!(client.access(), Access::View);
let mut d = client.document().clone();
d.node_mut(rect).unwrap().name = "Sneaky".into();
raw.send(&client.edit(&d).unwrap().unwrap());
loop {
if let ServerMsg::Reject { reason, .. } = raw.next() {
assert_eq!(reason, VIEW_ONLY);
break;
}
}
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
// Leaving keeps a local copy.
c.execute("collab.leave", &json!({})).unwrap();
assert_eq!(c.live.status, Status::Off);
assert!(c.doc().unwrap().contains(rect));
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
server.stop();
}
#[test]
fn a_restart_mid_session_loses_no_confirmed_edit() {
let dir = TempDir::new("restart");
let mut cfg = config(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
// The server stops: both reconnect, and what Ada makes meanwhile waits.
server.stop();
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
cfg.listen = addr.to_string();
let mut server = start_again(&cfg);
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
});
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
// Someone new gets it all from the restarted server.
let mut c = join(&p.view, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]));
assert!(same(a.doc().unwrap(), c.doc().unwrap()));
server.stop();
}
/// Start a server on `cfg.listen` again (the port may take a moment to come free).
fn start_again(cfg: &crate::config::Config) -> crate::Running {
let start = std::time::Instant::now();
loop {
match crate::start(cfg) {
Ok(s) => return s,
Err(e) if start.elapsed() < Duration::from_secs(10) && e.contains("can't listen") => std::thread::sleep(Duration::from_millis(50)),
Err(e) => panic!("{e}"),
}
}
}
#[test]
fn revoked_links_stop_working_at_once() {
let dir = TempDir::new("revoke");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let file = Invite::parse(&p.edit).unwrap().file;
let endpoint = Endpoint::plain(addr.to_string());
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
let AdminReply::Link(review) = ask(Admin::CreateLink { file: file.clone(), access: Access::View, label: "Review".into() }) else { panic!() };
let link = Invite { server: endpoint.clone(), file: file.clone(), token: review.token.clone() }.link();
let mut b = join(&p.edit, "Bo");
let mut c = join(&link, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)));
let AdminReply::Files(files) = ask(Admin::Files) else { panic!() };
assert_eq!((files.len(), files[0].people), (1, 3));
// Revoked: Cy is out at once, the others stay.
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
});
assert!(live(&b));
// The link no longer joins.
c.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
// Managing needs the key.
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("wrong server key"), "{e}");
// Removing the file ends it for everyone in it.
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b], "everyone out", |s| {
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
});
server.stop();
}
#[test]
fn fuzzed_frames_dont_hurt_the_server() {
let dir = TempDir::new("fuzz");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let edit = Invite::parse(&p.edit).unwrap();
let valid: Vec<Vec<u8>> = [
Client::hello(&edit.file, &edit.token, "Fuzz", ""),
ClientMsg::Manage(message::Manage { protocol: message::PROTOCOL, key: key(dir.path()), client_version: String::new() }),
ClientMsg::Admin(Admin::Links { file: "0000000000000000".into() }),
ClientMsg::Ping,
]
.iter()
.map(|m| message::encode(m).unwrap())
.collect();
let mut rng = Rng::new(11);
for round in 0..80 {
let Ok(s) = TcpStream::connect(addr) else { continue };
let _ = s.set_read_timeout(Some(Duration::from_millis(200)));
let Ok((mut ws, _)) = tungstenite::client::client(format!("ws://{addr}/"), s) else { continue };
for _ in 0..1 + rng.below(3) {
let mut bytes = rng.pick(&valid).cloned().unwrap_or_default();
if round % 5 == 0 {
bytes = vec![0x5A; 100_000];
}
for _ in 0..1 + rng.below(4) {
if bytes.is_empty() {
break;
}
let i = rng.below(bytes.len());
match rng.below(3) {
0 => bytes[i] = rng.next_u64() as u8,
1 => bytes.insert(i, rng.next_u64() as u8),
_ => bytes.truncate(i.max(1)),
}
}
let frame =
if round % 7 == 0 { Message::Text(String::from_utf8_lossy(&bytes).into_owned().into()) } else { Message::Binary(Bytes::from(bytes)) };
if ws.send(frame).is_err() {
break;
}
}
let _ = ws.read();
}
// Still serving, the file intact.
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in after the noise", |s| live(s[1]) && live(s[0]));
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
server.stop();
}

View File

@ -0,0 +1,615 @@
//! The data directory: the files a server keeps, their share links and the server key, and the
//! [`Backend`] the hub serves them through.
//!
//! ```text
//! <data>/LOCK held while a server (or a command changing the directory) uses it
//! <data>/server.json where the running server listens (for the command line)
//! <data>/server.key the server key: managing files (publish, links, removal)
//! <data>/files/<id>/meta.json name, created, updated, sequence number
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
//! <data>/files/<id>/log.bin what changed since (see `disk`)
//! ```
//!
//! An open file appends its server's journal to its log; the hub has the store make the appends
//! durable before anyone hears of them. After [`StoreConfig::compact_after`] entries or
//! [`StoreConfig::compact_bytes`] of log, when everyone has left, and when the server stops, the
//! state is written as the next snapshot and the log starts over. Link tokens are stored only as
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
//! never deleted.
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
use figcraft_collab::{Change, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile};
use figcraft_collab_net::token;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::disk::{self, LINKS, LOG, LOG_HEADER, META, SNAPSHOT};
pub const LOCK: &str = "LOCK";
pub const KEY: &str = "server.key";
pub const FILES: &str = "files";
/// What the store enforces.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StoreConfig {
pub max_files: usize,
pub max_file_bytes: u64,
pub max_links: usize,
pub compact_after: usize,
pub compact_bytes: u64,
pub core: figcraft_collab::Limits,
}
/// A file's description (`meta.json`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Meta {
name: String,
created: u64,
updated: u64,
#[serde(default)]
seq: u64,
}
/// A share link (`links.json`): the token itself is never stored.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Link {
id: String,
access: Access,
label: String,
created: u64,
/// SHA-256 of the token, hex.
hash: String,
}
impl Link {
fn info(&self, file: &str) -> LinkInfo {
LinkInfo { id: self.id.clone(), file: file.to_string(), access: self.access, label: self.label.clone(), created: self.created }
}
}
/// An open file's log.
struct Log {
generation: u64,
file: File,
/// Records not written yet (written and synced at the next flush).
pending: Vec<u8>,
/// Entries and bytes since the last compaction.
entries: usize,
bytes: u64,
/// Changed since the last compaction.
dirty: bool,
}
struct Entry {
meta: Meta,
links: Vec<Link>,
/// Open: its log.
log: Option<Log>,
}
/// The data directory in use (it holds the directory's lock until dropped).
pub struct Store {
root: PathBuf,
cfg: StoreConfig,
key: String,
files: BTreeMap<String, Entry>,
/// What was skipped at startup or found damaged since, for the operator.
problems: Vec<String>,
_lock: File,
}
/// Seconds since 1970.
pub fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// SHA-256 of a token, hex.
pub fn hash(token: &str) -> String {
Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
/// A file or link id as this store makes them (and nothing a path could misuse).
fn valid_id(s: &str) -> bool {
(1..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// A name others may see: trimmed, no control characters, at most 256 characters.
fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
}
/// Take the data directory's lock: `None` when someone else holds it.
pub fn lock(root: &Path) -> Result<Option<File>, String> {
fs::create_dir_all(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
let f = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(root.join(LOCK))
.map_err(|e| format!("can't open {}: {e}", root.join(LOCK).display()))?;
match f.try_lock() {
Ok(()) => Ok(Some(f)),
Err(TryLockError::WouldBlock) => Ok(None),
Err(TryLockError::Error(e)) => Err(format!("can't lock the data directory: {e}")),
}
}
/// Read the server key, or make one.
fn load_key(root: &Path) -> Result<String, String> {
let path = root.join(KEY);
match fs::read_to_string(&path) {
Ok(k) if valid_id(k.trim()) && k.trim().len() >= 16 => Ok(k.trim().to_string()),
Ok(_) => Err(format!("{} isn't a server key (delete it to make a new one)", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => write_key(root),
Err(e) => Err(format!("can't read {}: {e}", path.display())),
}
}
/// Make a new server key (only the operator's account can read it on Unix).
fn write_key(root: &Path) -> Result<String, String> {
let key = token::secret()?;
let path = root.join(KEY);
disk::write_atomic(&path, format!("{key}\n").as_bytes()).map_err(|e| format!("can't write {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(key)
}
impl Store {
/// Use the data directory `root` (made if missing). Fails when another server or command holds
/// it. Files that can't be read are skipped and listed in [`problems`](Self::problems).
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))
}
/// [`open`](Self::open), or `None` when someone else holds the directory.
pub fn try_open(root: &Path, cfg: StoreConfig) -> Result<Option<Store>, String> {
let Some(lock) = lock(root)? else { return Ok(None) };
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), problems: Vec::new(), _lock: lock };
store.scan(&dir);
Ok(Some(store))
}
fn scan(&mut self, dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
};
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
ids.sort();
for (id, path) in ids {
if !path.is_dir() {
continue;
}
if !valid_id(&id) {
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
continue;
}
if self.files.len() >= self.cfg.max_files {
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
continue;
}
match self.check(&path) {
Ok(entry) => {
self.files.insert(id, entry);
}
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
}
}
}
/// A file folder's description and links, after checking its snapshot and log. Leftover
/// temporary files (writes cut short) are removed.
fn check(&self, path: &Path) -> Result<Entry, String> {
for name in [SNAPSHOT, LOG, META, LINKS] {
let tmp = disk::temp_of(&path.join(name));
if tmp.exists() {
let _ = fs::remove_file(&tmp);
}
}
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec<Link> =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
// A damaged log only loses its end; it is reported when the file opens.
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
&& let Ok(log) = disk::read_log(&bytes)
&& log.generation != generation
{
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
}
Ok(Entry { meta, links, log: None })
}
/// The server key (for managing files).
pub fn key(&self) -> &str {
&self.key
}
/// Replace the server key; the new one.
pub fn rotate_key(&mut self) -> Result<String, String> {
self.key = write_key(&self.root)?;
Ok(self.key.clone())
}
/// What was skipped or found damaged.
pub fn problems(&self) -> &[String] {
&self.problems
}
/// How many files are kept.
pub fn len(&self) -> usize {
self.files.len()
}
/// No files are kept.
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
fn dir(&self, id: &str) -> PathBuf {
self.root.join(FILES).join(id)
}
fn entry(&mut self, id: &str) -> Result<&mut Entry, String> {
self.files.get_mut(id).ok_or_else(|| "this file isn't on the server (it may have been removed)".to_string())
}
fn info(&self, id: &str, e: &Entry, open: Option<OpenFile>) -> FileInfo {
let dir = self.dir(id);
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(dir.join(n)).ok()).map(|m| m.len()).sum();
FileInfo {
id: id.to_string(),
name: e.meta.name.clone(),
created: e.meta.created,
updated: e.meta.updated,
seq: open.map_or(e.meta.seq, |o| o.seq),
bytes,
people: open.map_or(0, |o| o.people),
}
}
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
if self.files.len() >= self.cfg.max_files {
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
}
let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
if replica.document().node_count() > self.cfg.core.max_nodes {
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
}
let mut doc = replica.document().clone();
let name = match clean_name(name) {
n if n.is_empty() => doc.name.clone(),
n => n,
};
doc.name = name.clone();
let server = Server::new(doc, "", self.cfg.core);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(1, &state)?;
if bytes.len() as u64 > self.cfg.max_file_bytes {
return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
}
let id = loop {
let id = token::file_id()?;
if !self.files.contains_key(&id) && !self.dir(&id).exists() {
break id;
}
};
let t = now();
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
let meta = Meta { name, created: t, updated: t, seq: 0 };
let dir = self.dir(&id);
let written = fs::create_dir_all(&dir)
.map_err(|e| e.to_string())
.and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()))
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
// Last: a folder without its description is an upload cut short.
.and_then(|()| write_json(&dir.join(META), &meta));
if let Err(e) = written {
let _ = fs::remove_dir_all(&dir);
return Err(format!("couldn't keep the file: {e}"));
}
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None };
let info = self.info(&id, &entry, None);
self.files.insert(id.clone(), entry);
log::info!("published file {id} ({} bytes)", bytes.len());
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }))
}
/// The files kept.
pub fn list(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileInfo> {
self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
}
/// A file's links.
pub fn links(&mut self, file: &str) -> Result<Vec<LinkInfo>, String> {
Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
}
/// A new link to a file.
pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result<NewLink, String> {
let max = self.cfg.max_links;
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
if entry.links.len() >= max {
return Err(format!("a file has at most {max} links: revoke one first"));
}
let (link, token) = new_link(access, &clean_name(label), now())?;
entry.links.push(link.clone());
if let Err(e) = write_json(&path, &entry.links) {
entry.links.pop();
return Err(format!("couldn't keep the link: {e}"));
}
Ok(NewLink { link: link.info(file), token })
}
/// Revoke a link.
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
let gone = entry.links.remove(at);
if let Err(e) = write_json(&path, &entry.links) {
entry.links.insert(at, gone);
return Err(format!("couldn't revoke the link: {e}"));
}
log::info!("revoked link {link} of file {file}");
Ok(())
}
/// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
/// longer served either way).
pub fn remove(&mut self, file: &str) -> Result<(), String> {
// Closed first: an open log would keep the folder from being deleted on Windows.
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
drop(entry);
let dir = self.dir(file);
fs::remove_dir_all(&dir)
.map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
log::info!("removed file {file}");
Ok(())
}
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
fn load(&mut self, file: &str) -> Result<Server, String> {
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
let mut issues = Vec::new();
let mut fresh_log = true;
let mut kept = 0;
match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) {
Ok(log) if log.generation == generation => {
fresh_log = false;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
fresh_log = true;
break;
}
kept += 1;
}
if let Some(why) = log.damaged {
issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
fresh_log = true;
}
}
// From before a compaction that was cut short: the snapshot has it all.
Ok(_) => {}
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
}
for i in issues {
log::warn!("{i}");
self.problems.push(i);
}
server.journaling(true);
let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
let log = match (fresh_log, file_handle) {
(false, Ok(f)) => {
let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
// Replayed entries make it worth compacting once everyone has left.
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
_ => self.rewrite(file, generation + 1, &server)?,
};
self.entry(file)?.log = Some(log);
Ok(server)
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
let dir = self.dir(file);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(generation, &state)?;
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
if let Ok(e) = self.entry(file) {
e.meta.seq = server.seq();
let meta = e.meta.clone();
if let Err(err) = write_json(&dir.join(META), &meta) {
log::warn!("file {file}: couldn't update {META}: {err}");
}
}
Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
}
/// Compact an open file: its state as the next snapshot, an empty log.
fn compact(&mut self, file: &str, server: &Server) {
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
match self.rewrite(file, generation + 1, server) {
Ok(log) => {
if let Ok(e) = self.entry(file) {
e.log = Some(log);
}
log::debug!("compacted file {file}");
}
// The old snapshot and log still hold everything: carry on appending.
Err(e) => log::error!("file {file}: compaction failed: {e}"),
}
}
/// Write and sync what's pending in every open log.
fn flush_all(&mut self) {
for (id, e) in &mut self.files {
if let Some(log) = e.log.as_mut() {
flush_log(id, log);
}
}
}
}
/// Write and sync a log's pending records (kept for the next try when that fails).
fn flush_log(id: &str, log: &mut Log) {
if log.pending.is_empty() {
return;
}
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
Ok(()) => {
log.bytes += log.pending.len() as u64;
log.pending.clear();
}
Err(e) => log::error!("file {id}: couldn't write its log: {e}"),
}
}
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
disk::write_atomic(path, &text).map_err(|e| e.to_string())
}
/// A new link and its token.
fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> {
let token = token::secret()?;
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token))
}
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
let entry = self.entry(file)?;
let presented = hash(token);
// Every link is compared (in constant time), so timing doesn't tell which one matched.
let mut found = None;
for l in &entry.links {
if same_secret(&presented, &l.hash) {
found = Some(Admission { access: l.access, link: Some(l.id.clone()) });
}
}
found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
}
fn open(&mut self, file: &str) -> Result<Server, String> {
self.load(file).map_err(|e| {
let why = format!("file {file} can't be opened: {e}");
log::error!("{why}");
self.problems.push(why);
"this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
})
}
fn changed(&mut self, file: &str, server: &mut Server) {
let journal = server.take_journal();
if journal.is_empty() {
return;
}
let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
let Some(e) = self.files.get_mut(file) else { return };
let Some(log) = e.log.as_mut() else { return };
for j in &journal {
match disk::record(j) {
Ok(r) => log.pending.extend(r),
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
}
}
log.entries += journal.len();
log.dirty = true;
e.meta.updated = now();
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
// The snapshot holds what's pending too.
self.compact(file, server);
}
}
fn flush(&mut self) {
self.flush_all();
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
flush_log(file, log);
log.dirty
});
if dirty {
self.compact(file, server);
}
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
true
}
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
self.flush_all();
for (file, server) in open {
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
self.compact(file, server);
}
}
}
fn manage(&mut self, key: &str) -> Result<(), String> {
if same_secret(key, &self.key) { Ok(()) } else { Err("wrong server key".into()) }
}
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
match req {
Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
Err(e) => failed(e),
},
Admin::Files => AdminReply::Files(self.list(open)).into(),
Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
Admin::Revoke { file, link } => match self.revoke(&file, &link) {
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None },
Err(e) => failed(e),
},
Admin::Remove { file } => {
if !self.files.contains_key(&file) {
return failed("this file isn't on the server (it may have been removed)".into());
}
let reply = match self.remove(&file) {
Ok(()) => AdminReply::Done,
Err(reason) => AdminReply::Failed { reason },
};
Handled { reply, revoked: None, removed: Some(file) }
}
}
}
fn closing(&self) -> Closing {
Closing::Restarting
}
}

View File

@ -0,0 +1,271 @@
//! The data directory on its own: publishing, links and their hashes, persistence across a crash
//! (snapshot plus log) and compaction, damaged files skipped and reported, caps, the lock, and
//! temporary directories cleaned up.
use std::fs;
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Client, Outgoing, Replica, Server};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::{Document, NodeKind};
use crate::disk::{self, LINKS, LOG, META, SNAPSHOT};
use crate::store::{FILES, Store, StoreConfig, hash};
use crate::test_util::{TempDir, config};
fn cfg(dir: &std::path::Path) -> StoreConfig {
config(dir).store()
}
fn doc(name: &str) -> Document {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
d
}
fn snapshot(d: Document) -> Vec<figcraft_collab::Change> {
Replica::new(d).snapshot().unwrap()
}
/// Publish `name`; its id and the edit and view tokens.
fn publish(store: &mut Store, name: &str) -> (String, String, String) {
let (info, edit, view) = store.publish(name, &snapshot(doc(name))).unwrap();
assert_eq!(edit.link.access, Access::Edit);
assert_eq!(view.link.access, Access::View);
(info.id, edit.token, view.token)
}
/// A client in `server` (as the hub would let it in) and what it was told.
fn join(server: &mut Server, access: Access) -> Client {
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
let (_, out) = server.join_as(h, access).unwrap();
let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() };
Client::join(w).unwrap()
}
/// Rename the first page through `c`; the server takes the batch.
fn rename(server: &mut Server, c: &mut Client, name: &str) {
let mut d = c.document().clone();
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().name = name.into();
let m = c.edit(&d).unwrap().unwrap();
for o in server.receive(c.id(), m) {
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
c.receive(m, &figcraft_collab::NoRecompute).unwrap();
}
}
}
#[test]
fn files_links_and_access() {
let dir = TempDir::new("links");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert!(store.is_empty());
let (id, edit, view) = publish(&mut store, "Café ☕");
let files = store.list(&|_| None);
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "Café ☕");
assert!(files[0].bytes > 0);
// Each link lets in with its access; anything else doesn't.
assert_eq!(store.admit(&id, &edit).unwrap().access, Access::Edit);
assert_eq!(store.admit(&id, &view).unwrap().access, Access::View);
assert!(store.admit(&id, "WrongWrongWrongWrong00").unwrap_err().contains("isn't valid"));
assert!(store.admit("0000000000000000", &edit).unwrap_err().contains("isn't on the server"));
// Tokens are kept only as hashes.
let on_disk = fs::read_to_string(dir.path().join(FILES).join(&id).join(LINKS)).unwrap();
assert!(!on_disk.contains(&edit) && !on_disk.contains(&view));
assert!(on_disk.contains(&hash(&edit)) && on_disk.contains(&hash(&view)));
// More links; revoking one stops it, the others keep working.
let review = store.create_link(&id, Access::View, " Review\n").unwrap();
assert_eq!(review.link.label, "Review");
assert_eq!(store.links(&id).unwrap().len(), 3);
assert_eq!(store.admit(&id, &review.token).unwrap().link.as_deref(), Some(review.link.id.as_str()));
store.revoke(&id, &review.link.id).unwrap();
assert!(store.admit(&id, &review.token).is_err());
assert!(store.admit(&id, &view).is_ok());
assert!(store.revoke(&id, &review.link.id).is_err(), "already gone");
// Removed for good.
store.remove(&id).unwrap();
assert!(store.is_empty());
assert!(!dir.path().join(FILES).join(&id).exists());
assert!(store.admit(&id, &edit).is_err());
}
#[test]
fn a_file_survives_a_crash_and_compacts() {
let dir = TempDir::new("crash");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, before, seq) = {
let mut store = Store::open(dir.path(), c).unwrap();
let (id, _, _) = publish(&mut store, "Crash");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
for i in 0..5 {
rename(&mut server, &mut a, &format!("Page {i}"));
store.changed(&id, &mut server);
}
store.flush();
// A crash: no compaction, nothing else written.
(id, server.replica().clone(), server.seq())
};
let mut store = Store::open(dir.path(), c).unwrap();
assert!(store.problems().is_empty(), "{:?}", store.problems());
let mut server = store.open(&id).unwrap();
assert_eq!(server.replica(), &before, "the log brought it back");
assert_eq!(server.seq(), seq);
// Compaction: everyone left; the snapshot has it all and the log starts over.
assert!(store.idle(&id, &mut server));
let snap = disk::decode_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(snap.1.seq, seq);
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
assert_eq!(log.generation, snap.0);
assert!(log.entries.is_empty());
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
let generation = || disk::check_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(generation(), 1);
for i in 0..3 {
rename(&mut server, &mut a, &format!("P{i}"));
store.changed(&id, &mut server);
store.flush();
}
assert_eq!(generation(), 2, "compacted after three entries");
rename(&mut server, &mut a, "After");
store.changed(&id, &mut server);
store.flush();
drop(store);
let mut store = Store::open(dir.path(), c).unwrap();
let restored = store.open(&id).unwrap();
assert_eq!(restored.replica(), server.replica());
}
#[test]
fn damaged_files_are_skipped_and_reported() {
let dir = TempDir::new("damaged");
let (bad_snapshot, bad_meta, cut_log, good) = {
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let ids: Vec<String> = ["A", "B", "C", "D"].iter().map(|n| publish(&mut store, n).0).collect();
// C gets a log with entries.
let mut server = store.open(&ids[2]).unwrap();
let mut c = join(&mut server, Access::Edit);
store.changed(&ids[2], &mut server);
rename(&mut server, &mut c, "Kept");
store.changed(&ids[2], &mut server);
rename(&mut server, &mut c, "Lost");
store.changed(&ids[2], &mut server);
store.flush();
(ids[0].clone(), ids[1].clone(), ids[2].clone(), ids[3].clone())
};
let folder = |id: &str| dir.path().join(FILES).join(id);
// A: a flipped byte in its snapshot; B: garbage description; C: its log cut mid-record;
// D: a write that was cut short left a temporary file.
let mut snap = fs::read(folder(&bad_snapshot).join(SNAPSHOT)).unwrap();
let last = snap.len() - 1;
snap[last] ^= 0xFF;
fs::write(folder(&bad_snapshot).join(SNAPSHOT), snap).unwrap();
fs::write(folder(&bad_meta).join(META), b"{not json").unwrap();
let log = fs::read(folder(&cut_log).join(LOG)).unwrap();
fs::write(folder(&cut_log).join(LOG), &log[..log.len() - 3]).unwrap();
fs::write(disk::temp_of(&folder(&good).join(SNAPSHOT)), b"half").unwrap();
fs::create_dir_all(dir.path().join(FILES).join("not a file id")).unwrap();
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let problems = store.problems().join("\n");
assert!(problems.contains(&bad_snapshot) && problems.contains("checksum"), "{problems}");
assert!(problems.contains(&bad_meta) && problems.contains(META), "{problems}");
assert!(problems.contains("not a file id"), "{problems}");
assert_eq!(store.len(), 2, "C and D are served");
// Nothing is deleted.
assert!(folder(&bad_snapshot).exists() && folder(&bad_meta).exists());
assert!(!disk::temp_of(&folder(&good).join(SNAPSHOT)).exists(), "the leftover is gone");
// C opens without the cut-short record, and says so.
let server = store.open(&cut_log).unwrap();
let page = server.document().first_page().unwrap();
assert_eq!(server.document().node(page).unwrap().name, "Kept");
assert!(store.problems().iter().any(|p| p.contains(&cut_log) && p.contains("cut short")));
// Damage after startup: the file can't be opened, with a reason for the person joining.
fs::write(folder(&good).join(SNAPSHOT), b"FCSN garbage").unwrap();
assert!(store.open(&good).unwrap_err().contains("can't be opened"));
assert!(store.problems().iter().any(|p| p.contains(&good)));
}
#[test]
fn caps_hold() {
let dir = TempDir::new("caps");
let c = StoreConfig { max_files: 1, max_links: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c).unwrap();
let (id, _, _) = publish(&mut store, "One");
assert!(store.publish("Two", &snapshot(doc("Two"))).unwrap_err().contains("at most 1"));
store.create_link(&id, Access::View, "").unwrap();
assert!(store.create_link(&id, Access::View, "").unwrap_err().contains("at most 3"));
let dir2 = TempDir::new("caps-size");
let mut tiny = Store::open(dir2.path(), StoreConfig { max_file_bytes: 64, ..cfg(dir2.path()) }).unwrap();
assert!(tiny.publish("Big", &snapshot(doc("Big"))).unwrap_err().contains("MiB"));
assert!(tiny.publish("Junk", &[figcraft_collab::Change::Delete { id: figcraft_doc::NodeId(1) }]).is_err());
}
#[test]
fn one_server_per_data_directory() {
let dir = TempDir::new("lock");
let first = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert!(Store::open(dir.path(), cfg(dir.path())).err().unwrap().contains("is using"));
assert!(Store::try_open(dir.path(), cfg(dir.path())).unwrap().is_none());
let key = first.key().to_string();
drop(first);
let again = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert_eq!(again.key(), key, "the key is kept");
}
#[test]
fn temporary_directories_are_removed() {
let path = {
let dir = TempDir::new("cleanup");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
publish(&mut store, "Gone");
drop(store);
dir.path().to_path_buf()
};
assert!(!path.exists());
}
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
let state = Server::new(doc("Fuzz"), "", figcraft_collab::Limits::default()).state().unwrap();
let snap = disk::encode_snapshot(3, &state).unwrap();
let mut log = disk::log_header(3);
log.extend(disk::record(&figcraft_collab::Journal::Issued { client: 9 }).unwrap());
assert_eq!(disk::decode_snapshot(&snap).unwrap().1, state);
assert_eq!(disk::read_log(&log).unwrap().entries.len(), 1);
for round in 0..5_000 {
let base = if round % 2 == 0 { &snap } else { &log };
let mut bytes = base.clone();
for _ in 0..1 + rng.below(4) {
if bytes.is_empty() {
break;
}
let i = rng.below(bytes.len());
match rng.below(3) {
0 => bytes[i] = rng.next_u64() as u8,
1 => bytes.insert(i, rng.next_u64() as u8),
_ => bytes.truncate(i),
}
}
let _ = disk::decode_snapshot(&bytes);
let _ = disk::check_snapshot(&bytes);
let _ = disk::read_log(&bytes);
}
}

View File

@ -0,0 +1,83 @@
//! What the tests share: temporary data directories (removed when dropped), settings, and engine
//! sessions linked over loopback.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
use figcraft_collab_net::NetLink;
use figcraft_collab_net::guest::GuestConfig;
use figcraft_engine::Session;
use serde_json::json;
use crate::config::Config;
/// A directory of its own under the system's temporary directory, removed with everything in it
/// when dropped.
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
static NEXT: AtomicU32 = AtomicU32::new(0);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join("figcraft-server-tests").join(format!("{tag}-{}-{nanos}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Settings for a server on `dir`, listening on a free loopback port.
pub fn config(dir: &Path) -> Config {
Config { listen: "127.0.0.1:0".into(), data: dir.to_path_buf(), ..Config::default() }
}
/// Joins quickly, and keeps trying long enough to outlast a server restart.
pub fn quick(mut c: GuestConfig) -> GuestConfig {
c.backoff = Duration::from_millis(20);
c.max_backoff = Duration::from_millis(200);
c.attempts = 100;
c.limits.hello_timeout = Duration::from_secs(5);
c
}
/// An engine session with the network link, named `name`.
pub fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump every session (as their windows would each frame) until `f` holds.
pub fn pump(sessions: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
let start = Instant::now();
loop {
for s in sessions.iter_mut() {
s.live_poll();
s.live_sync();
}
if f(sessions) {
return;
}
let states: Vec<String> = sessions.iter().map(|s| s.live_summary().to_string()).collect();
assert!(start.elapsed() < Duration::from_secs(20), "timed out waiting for {what}: {states:#?}");
std::thread::sleep(Duration::from_millis(5));
}
}
/// The synced properties of two documents agree.
pub fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
}

View File

@ -139,7 +139,7 @@ impl<S: Socket> Driver<S> {
pub(crate) fn closed(&mut self, code: u16, reason: &str, now: Instant) {
let Some(opened) = self.conn.as_ref().map(|c| c.opened) else { return };
let outcome = match closed(code, reason) {
Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)),
Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())),
Outcome::Retry(_) if code == 1006 => Outcome::Retry("the connection dropped".into()),
o => o,
};
@ -206,7 +206,7 @@ impl<S: Socket> Driver<S> {
let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)), now);
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
}
return;
}

View File

@ -57,7 +57,7 @@ impl Socket for Script {
}
fn invite() -> Invite {
Invite { host: "127.0.0.1:47900".into(), file: FILE.into(), token: TOKEN.into() }
Invite::new("127.0.0.1:47900", FILE, TOKEN)
}
fn start(sock: Script, t0: Instant) -> (Driver<Script>, Inbox) {
@ -363,7 +363,7 @@ fn joins_a_real_host_and_edits_with_it() {
host_probe.clone(),
)
.unwrap();
let invite = Invite { host: host.addr().to_string(), file: FILE.into(), token: TOKEN.into() };
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
let inbox = Inbox::new(Arc::new(|| {}));
let mut d = Driver::start(Loopback::default(), GuestConfig::new(invite, "Ada".into()), inbox.clone(), Instant::now());
let mut events = Vec::new();

View File

@ -4,26 +4,43 @@
//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
//! When the connection drops after having been live, it reconnects with growing pauses; a
//! refusal or the host ending the session is final.
//!
//! Publishing ([`Guest::publish`]) first uploads the document to a server over a managing
//! connection, then joins the new file with its edit link on the same thread.
use std::io;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::net::TcpStream;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel};
use std::thread::JoinHandle;
use std::time::Instant;
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_engine::live::{Event, Status};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, Published, Status};
use tungstenite::protocol::frame::coding::CloseCode;
use tungstenite::protocol::{CloseFrame, Role as WsRole, WebSocket};
use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
use crate::net::{Duplex, config, shutdown};
use crate::manage;
use crate::net::{Duplex, config, dial, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// What [`Guest::publish`] uploads, and where.
pub struct Publish {
pub server: Endpoint,
/// The server's key.
pub key: String,
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
}
/// A joined (or joining) session. Dropping it leaves.
pub struct Guest {
cmd: Sender<Cmd>,
@ -50,6 +67,37 @@ impl Guest {
Ok(Guest { cmd: tx, thread: Some(thread) })
}
/// Upload `job.doc` to the server as a new file, then join it with its edit link (returns at
/// once: [`Event::Published`] with the file's links, then the session, arrive in `inbox`).
/// `cfg` says how to join; its invite is replaced by the new file's.
pub fn publish(job: Publish, cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
let (tx, rx) = channel();
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-publish".into()).spawn(move || {
inbox.push(Event::Status(Status::Connecting));
let (cfg, published) = match upload(&job, cfg) {
Ok(done) => done,
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
};
inbox.push(Event::Published(published));
// Left while uploading: the file stays on the server, nobody joins it. Anything else
// that came meanwhile (presence) waits for the session.
let mut waiting = Vec::new();
loop {
match rx.try_recv() {
Ok(Cmd::Leave) | Err(TryRecvError::Disconnected) => return,
Ok(c) => waiting.push(c),
Err(TryRecvError::Empty) => break,
}
}
for c in waiting {
let _ = me.send(c);
}
supervise(&cfg, &inbox, &rx, &me);
})?;
Ok(Guest { cmd: tx, thread: Some(thread) })
}
/// Your latest presence.
pub fn presence(&self, p: Presence) {
let _ = self.cmd.send(Cmd::Presence(p));
@ -90,26 +138,28 @@ struct Conn {
sock: TcpStream,
}
/// Upload the document; the join settings for the new file and its links.
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
let snapshot = Replica::new((*job.doc).clone()).snapshot().map_err(|e| e.to_string())?;
let replies =
manage::request(&job.server, &job.key, vec![Admin::Publish { name: job.name.clone(), snapshot }], &cfg.limits, cfg.connect_timeout)?;
let (file, edit, view) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
Some(AdminReply::Failed { reason }) => return Err(reason),
_ => return Err("the server didn't say where the file is".into()),
};
let link = |token: String| Invite { server: job.server.clone(), file: file.id.clone(), token };
let (edit, view) = (link(edit.token), link(view.token));
let published = Published { server: job.server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
cfg.invite = edit;
Ok((cfg, published))
}
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
let addrs: Vec<SocketAddr> = cfg.invite.host.to_socket_addrs().map_err(|e| format!("can't find {}: {e}", cfg.invite.host))?.collect();
let mut last = format!("can't find {}", cfg.invite.host);
let mut stream = None;
for a in addrs {
match TcpStream::connect_timeout(&a, cfg.connect_timeout) {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last = format!("no answer from {a}: {e}"),
}
}
let stream = stream.ok_or(last)?;
let _ = stream.set_nodelay(true);
stream.set_read_timeout(Some(cfg.limits.hello_timeout)).map_err(|e| e.to_string())?;
stream.set_write_timeout(Some(cfg.limits.write_timeout)).map_err(|e| e.to_string())?;
let stream = dial(&cfg.invite.server.host, cfg.connect_timeout, &cfg.limits)?;
let (rd, wr, sock) = Duplex::split(stream).map_err(|e| e.to_string())?;
let (reader, _) = tungstenite::client::client_with_config(cfg.invite.endpoint(), rd, Some(config(cfg.limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft session: {e}", cfg.invite.host))?;
.map_err(|e| format!("{} doesn't answer as a FigCraft session: {e}", cfg.invite.server.display()))?;
let writer = WebSocket::from_raw_socket(wr, WsRole::Client, Some(config(cfg.limits.max_client_message)));
reader.get_ref().set_read_timeout(Some(cfg.limits.idle_timeout)).map_err(|e| e.to_string())?;
Ok(Conn { reader, writer, sock })

View File

@ -1,40 +1,20 @@
//! The session host the app embeds: a WebSocket server for one file that runs the collaboration
//! core's [`Server`] (the ordering authority) and carries its messages.
//!
//! Threads: one accepts connections; each connection has a reader and a writer (see [`crate::net`]);
//! one hub owns the [`Server`] and everything shared, fed by a channel. The person hosting takes
//! part through the hub directly (no socket): their presence goes in with [`Host::presence`],
//! what others do comes out through their [`Inbox`].
//!
//! On top of the core the hub checks the invite's file id, hands out colours and default names,
//! throttles presence to about 30 updates a second per person (latest wins), tells a newcomer
//! its own name and colour (a `PeerJoined` of itself, after its welcome) and where everyone
//! already is, and disconnects clients that send too much, too fast, or can't keep up.
//! The session host the app embeds: the shared [hub](crate::hub) serving one file, the one the
//! app shares, with the session's single token. The person hosting is in it without a socket:
//! their presence goes in with [`Host::presence`], their edits with [`Host::send`], what others do
//! comes out through their [`Inbox`]. When the host stops, the session ends for everyone.
use std::collections::{BTreeMap, HashMap};
use std::io;
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use std::net::{SocketAddr, TcpListener};
use figcraft_collab::message::{self, ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
use figcraft_collab::{Outgoing, Server};
use figcraft_collab::message::{Access, ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
use figcraft_collab::{Outgoing, Server, same_secret};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
use tungstenite::protocol::frame::coding::CloseCode;
use tungstenite::protocol::{CloseFrame, Role, WebSocket};
use tungstenite::{Bytes, Message};
use figcraft_engine::live::{Event, PEOPLE};
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name};
use crate::inbox::Inbox;
pub use crate::join::ENDED;
use crate::net::{Bucket, Duplex, NetLimits, config, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// How long a closing connection waits for the peer to hang up.
const LINGER: Duration = Duration::from_secs(2);
use crate::net::NetLimits;
/// What a hosted session needs.
pub struct HostConfig {
@ -55,50 +35,34 @@ pub struct HostConfig {
/// A running session host. Dropping it stops it.
pub struct Host {
addr: SocketAddr,
hosting: Hosting,
me: Peer,
hub: Sender<HubIn>,
stop: Arc<AtomicBool>,
open: Arc<AtomicUsize>,
hub_thread: Option<JoinHandle<()>>,
accept_thread: Option<JoinHandle<()>>,
}
/// A message for one connection's writer.
enum Out {
Bytes(Bytes),
Close(CloseCode, String),
/// The app's one file and its token: everyone with the invite edits.
struct OneFile {
file: String,
token: String,
}
enum HubIn {
Join {
conn: u64,
hello: Hello,
out: SyncSender<Out>,
sock: TcpStream,
},
Msg {
conn: u64,
msg: ClientMsg,
},
Gone {
conn: u64,
},
Local(Presence),
LocalMsg(ClientMsg),
/// Cut every connection without a word (tests: a network failure).
#[cfg(test)]
Cut,
Stop,
}
impl Backend for OneFile {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
if file != self.file {
return Err("this invite is for another file or an earlier session".into());
}
if !same_secret(token, &self.token) {
return Err("wrong session token".into());
}
Ok(Admission { access: Access::Edit, link: None })
}
fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result<JoinHandle<()>> {
std::thread::Builder::new().name(name.to_string()).spawn(f)
}
fn open(&mut self, _: &str) -> Result<Server, String> {
Err("this invite is for another file or an earlier session".into())
}
/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters.
fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect()
fn closing(&self) -> Closing {
Closing::Ended
}
}
impl Host {
@ -109,8 +73,8 @@ impl Host {
Err(e) if e.kind() == io::ErrorKind::AddrInUse && cfg.any_port_if_taken => TcpListener::bind(SocketAddr::new(cfg.addr.ip(), 0))?,
Err(e) => return Err(e),
};
let addr = listener.local_addr()?;
let mut server = Server::new(cfg.doc, cfg.token.clone(), cfg.core);
// The hub checks the token; the session itself keeps none.
let mut server = Server::new(cfg.doc, "", cfg.core);
let name = match clean_name(&cfg.name) {
n if n.is_empty() => "Host".to_string(),
n => n,
@ -118,7 +82,7 @@ impl Host {
let hello = Hello {
protocol: PROTOCOL,
file: cfg.file.clone(),
token: cfg.token,
token: String::new(),
name,
color: PEOPLE[0].to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
@ -134,40 +98,15 @@ impl Host {
}
}
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
let (hub_tx, hub_rx) = channel();
let stop = Arc::new(AtomicBool::new(false));
let open = Arc::new(AtomicUsize::new(0));
let hub = Hub {
server,
file: cfg.file,
local: me.client_id,
inbox,
conns: HashMap::new(),
ids: BTreeMap::new(),
throttles: BTreeMap::new(),
latest: BTreeMap::new(),
guests: 0,
};
let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?;
let accept_thread = {
let (hub_tx, stop, open, limits) = (hub_tx.clone(), stop.clone(), open.clone(), cfg.limits);
spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits))
};
let accept_thread = match accept_thread {
Ok(t) => t,
Err(e) => {
let _ = hub_tx.send(HubIn::Stop);
let _ = hub_thread.join();
return Err(e);
}
};
log::info!("live session: hosting on {addr}");
Ok(Host { addr, me, hub: hub_tx, stop, open, hub_thread: Some(hub_thread), accept_thread: Some(accept_thread) })
let backend = OneFile { file: cfg.file.clone(), token: cfg.token };
let local = Local { file: cfg.file, server, id: me.client_id, inbox };
let hosting = Hosting::start(listener, backend, cfg.limits, Some(local))?;
Ok(Host { hosting, me })
}
/// Where it listens.
pub fn addr(&self) -> SocketAddr {
self.addr
self.hosting.addr()
}
/// The host as the others see them.
@ -177,447 +116,27 @@ impl Host {
/// Open connections (joined or not).
pub fn connections(&self) -> usize {
self.open.load(Ordering::Relaxed)
self.hosting.connections()
}
/// The host's own presence.
pub fn presence(&self, p: Presence) {
let _ = self.hub.send(HubIn::Local(p));
self.hosting.presence(p);
}
/// A message from the host's own sync client (its edits).
pub fn send(&self, msg: ClientMsg) {
let _ = self.hub.send(HubIn::LocalMsg(msg));
self.hosting.send(msg);
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
let _ = self.hub.send(HubIn::Cut);
self.hosting.cut_connections();
}
/// End the session: everyone is told and disconnected, the port is released.
pub fn stop(&mut self) {
if self.stop.swap(true, Ordering::SeqCst) {
return;
}
let _ = self.hub.send(HubIn::Stop);
if let Some(t) = self.hub_thread.take() {
let _ = t.join();
}
// The accept loop checks the flag after each connection: wake it with one.
let wake = if self.addr.ip().is_unspecified() { SocketAddr::from(([127, 0, 0, 1], self.addr.port())) } else { self.addr };
let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok();
if let Some(t) = self.accept_thread.take() {
if woke {
let _ = t.join();
} else {
log::warn!("live session: couldn't wake the accept loop; it ends with the next connection");
}
}
// Readers end once their writers have closed the sockets.
let deadline = Instant::now() + Duration::from_secs(2);
while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
log::info!("live session: stopped hosting");
}
}
impl Drop for Host {
fn drop(&mut self) {
self.stop();
}
}
/// Counts an open connection until dropped.
struct Open(Arc<AtomicUsize>);
impl Drop for Open {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits) {
let mut next = 0u64;
for stream in listener.incoming() {
if stop.load(Ordering::SeqCst) {
break;
}
let stream = match stream {
Ok(s) => s,
Err(e) => {
// Out of sockets or similar: don't spin.
log::warn!("live session: accept failed: {e}");
std::thread::sleep(Duration::from_millis(50));
continue;
}
};
if open.load(Ordering::Relaxed) >= limits.max_connections {
log::info!("live session: too many connections; refusing one");
continue;
}
open.fetch_add(1, Ordering::Relaxed);
let guard = Open(open.clone());
next += 1;
let (conn, hub) = (next, hub.clone());
if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard)) {
log::warn!("live session: can't start a connection thread: {e}");
}
}
}
/// A close reason that fits a WebSocket close frame (123 bytes).
fn reason(s: &str) -> String {
let mut end = s.len().min(123);
while !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or_default().to_string()
}
fn encode(msg: &ServerMsg) -> Option<Bytes> {
match message::encode(msg) {
Ok(b) => Some(Bytes::from(b)),
Err(e) => {
log::warn!("live session: can't encode a message: {e}");
None
}
}
}
/// Queue `Refused {reason}` and a close for one connection.
fn refuse(out: &SyncSender<Out>, why: &str) {
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
let _ = out.try_send(Out::Bytes(b));
}
let _ = out.try_send(Out::Close(CloseCode::Policy, reason(why)));
}
/// Read the next binary message (skipping pings), or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<ClientMsg>, &'static str> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => message::decode::<ClientMsg>(&b, max).map(Some).map_err(|_| "that message couldn't be read"),
Ok(Message::Text(_)) => Err("only binary messages are spoken here"),
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
Ok(Message::Close(_)) => Ok(None),
Err(tungstenite::Error::Capacity(_)) => Err("message too large"),
Err(_) => Ok(None),
};
}
}
/// One connection's reader (the writer runs on its own thread).
fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _open: Open) {
let _ = stream.set_nodelay(true);
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
let Ok((rd, wr, sock)) = Duplex::split(stream) else { return };
let cfg = config(limits.max_client_message);
let mut ws = match tungstenite::accept_with_config(rd, Some(config(limits.max_hello))) {
Ok(ws) => ws,
Err(e) => {
log::debug!("live session: handshake failed: {e}");
shutdown(&sock);
return;
}
};
let (out, rx) = sync_channel::<Out>(limits.queue.max(4));
let writer =
match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) {
Ok(Ok(t)) => t,
_ => {
shutdown(&sock);
return;
}
};
let end = |why: Option<&str>| {
if let Some(why) = why {
refuse(&out, why);
} else {
let _ = out.try_send(Out::Close(CloseCode::Normal, String::new()));
}
};
match next_message(&mut ws, limits.max_hello) {
Ok(Some(ClientMsg::Hello(hello))) => {
ws.set_config(|c| *c = cfg);
let joined = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Join { conn, hello, out: out.clone(), sock: s }).is_ok());
if joined && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() {
read_loop(&mut ws, conn, hub, limits, &end);
let _ = hub.send(HubIn::Gone { conn });
}
}
Ok(Some(_)) => end(Some("say hello first")),
Ok(None) => {}
Err(why) => end(Some(why)),
}
linger(&mut ws);
drop(out);
// The writer closes the socket once it has sent what's queued (a refusal, a close).
let _ = writer.join();
shutdown(&sock);
}
fn read_loop(ws: &mut WebSocket<Duplex>, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, end: &dyn Fn(Option<&str>)) {
let mut bucket = Bucket::new(limits.rate, limits.burst);
loop {
match next_message(ws, limits.max_client_message) {
Ok(Some(msg)) => {
if !bucket.take(Instant::now()) {
log::info!("live session: connection {conn} sends too much; disconnecting it");
return end(Some("too many messages"));
}
if hub.send(HubIn::Msg { conn, msg }).is_err() {
return;
}
}
Ok(None) => return end(None),
Err(why) => return end(Some(why)),
}
}
}
fn write_loop(mut ws: WebSocket<Duplex>, rx: &Receiver<Out>, sock: &TcpStream) {
let mut close = Some((CloseCode::Normal, String::new()));
while let Ok(o) = rx.recv() {
match o {
Out::Bytes(b) => {
if ws.send(Message::Binary(b)).is_err() {
close = None;
break;
}
}
Out::Close(code, why) => {
close = Some((code, why));
break;
}
}
}
if let Some((code, why)) = close {
// Close politely: the close frame, then no more writing; the reader drains what the peer
// still sends until it hangs up (closing on unread data would reset the connection and
// could lose the close frame), or until the linger runs out.
let _ = ws.close(Some(CloseFrame { code, reason: why.into() }));
let _ = sock.shutdown(std::net::Shutdown::Write);
let until = Instant::now() + LINGER;
while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
if Instant::now() >= until {
break;
}
}
}
shutdown(sock);
}
/// Read and drop what the peer still sends after the close, until it hangs up (at most
/// [`LINGER`]).
fn linger(ws: &mut WebSocket<Duplex>) {
let until = Instant::now() + LINGER;
while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() {
match ws.read() {
Ok(Message::Close(_)) => break,
Ok(_) => {}
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
Err(_) => break,
}
}
}
/// A joined or joining connection.
struct Conn {
out: SyncSender<Out>,
sock: TcpStream,
id: u32,
}
/// Owns the collaboration server and routes its messages.
struct Hub {
server: Server,
file: String,
/// The host's own client id.
local: u32,
inbox: Inbox,
conns: HashMap<u64, Conn>,
/// Client id → connection.
ids: BTreeMap<u32, u64>,
throttles: BTreeMap<u32, Throttle<Presence>>,
/// Everyone's latest presence (for newcomers).
latest: BTreeMap<u32, Presence>,
/// Default names handed out ("Guest 1", "Guest 2"…).
guests: u32,
}
impl Hub {
fn run(mut self, rx: &Receiver<HubIn>) {
loop {
let due = earliest(self.throttles.values().map(Throttle::due));
let msg = match due {
Some(d) => match rx.recv_timeout(d.saturating_duration_since(Instant::now())) {
Ok(m) => Some(m),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => Some(HubIn::Stop),
},
None => Some(rx.recv().unwrap_or(HubIn::Stop)),
};
match msg {
Some(HubIn::Stop) => break,
Some(m) => self.handle(m),
None => {}
}
self.flush_due(Instant::now());
}
for c in self.conns.values() {
if c.out.try_send(Out::Close(CloseCode::Away, ENDED.into())).is_err() {
shutdown(&c.sock);
}
}
}
fn handle(&mut self, m: HubIn) {
match m {
HubIn::Join { conn, hello, out, sock } => self.join(conn, hello, out, sock),
HubIn::Msg { conn, msg } => {
let Some(id) = self.conns.get(&conn).map(|c| c.id) else { return };
match msg {
ClientMsg::Presence(p) => self.presence(id, p),
other => {
let outs = self.server.receive(id, other);
self.deliver(outs);
}
}
}
HubIn::Gone { conn } => self.gone(conn),
HubIn::Local(p) => self.presence(self.local, p),
HubIn::LocalMsg(ClientMsg::Presence(p)) => self.presence(self.local, p),
HubIn::LocalMsg(msg) => {
let outs = self.server.receive(self.local, msg);
self.deliver(outs);
}
#[cfg(test)]
HubIn::Cut => {
for c in self.conns.values() {
shutdown(&c.sock);
}
}
HubIn::Stop => {}
}
}
fn join(&mut self, conn: u64, mut hello: Hello, out: SyncSender<Out>, sock: TcpStream) {
if hello.file != self.file {
return refuse(&out, "this invite is for another file or an earlier session");
}
// Colours: the first one nobody has.
let taken: Vec<&str> = self.server.peers().map(|p| p.color.as_str()).collect();
let color = PEOPLE.iter().find(|c| !taken.contains(c)).copied().unwrap_or(PEOPLE[taken.len() % PEOPLE.len()]);
hello.color = color.to_string();
hello.name = match clean_name(&hello.name) {
n if n.is_empty() => {
self.guests += 1;
format!("Guest {}", self.guests)
}
n => n,
};
match self.server.join(hello) {
Ok((id, outs)) => {
// A guest back after a drop keeps its id: its old connection, if the hub hasn't
// noticed it's gone yet, is closed without the guest leaving.
if let Some(old) = self.ids.get(&id).copied().filter(|old| *old != conn)
&& let Some(c) = self.conns.remove(&old)
{
shutdown(&c.sock);
}
self.conns.insert(conn, Conn { out, sock, id });
self.ids.insert(id, conn);
self.deliver(outs);
// The newcomer learns how it is shown, then where everyone is.
let mut more = Vec::new();
if let Some(me) = self.server.peers().find(|p| p.client_id == id) {
more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone())));
}
more.extend(self.latest.iter().map(|(&from, p)| Outgoing::To(id, ServerMsg::Presence { client_id: from, presence: p.clone() })));
self.deliver(more);
}
Err(ServerMsg::Refused { reason }) => refuse(&out, &reason),
Err(_) => refuse(&out, "refused"),
}
}
fn gone(&mut self, conn: u64) {
let Some(c) = self.conns.remove(&conn) else { return };
self.ids.remove(&c.id);
self.throttles.remove(&c.id);
self.latest.remove(&c.id);
let outs = self.server.leave(c.id);
self.deliver(outs);
}
fn presence(&mut self, id: u32, p: Presence) {
let p = sanitize(p);
self.latest.insert(id, p.clone());
let now = Instant::now();
if let Some(p) = self.throttles.entry(id).or_insert_with(|| Throttle::new(PRESENCE_INTERVAL)).offer(p, now) {
let outs = self.server.receive(id, ClientMsg::Presence(p));
self.deliver(outs);
}
}
fn flush_due(&mut self, now: Instant) {
let due: Vec<(u32, Presence)> = self.throttles.iter_mut().filter_map(|(id, t)| t.take_due(now).map(|p| (*id, p))).collect();
for (id, p) in due {
let outs = self.server.receive(id, ClientMsg::Presence(p));
self.deliver(outs);
}
}
/// Send what the server said; connections that can't keep up are dropped.
fn deliver(&mut self, outs: Vec<Outgoing>) {
let mut stuck = Vec::new();
for o in outs {
let (to, msg): (Vec<u32>, ServerMsg) = match o {
Outgoing::To(id, m) => (vec![id], m),
Outgoing::All(m) => (self.server.peers().map(|p| p.client_id).collect(), m),
Outgoing::Others(x, m) => (self.server.peers().map(|p| p.client_id).filter(|id| *id != x).collect(), m),
};
let mut bytes = None;
for id in to {
if id == self.local {
self.to_local(&msg);
continue;
}
let Some(c) = self.ids.get(&id).and_then(|conn| self.conns.get(conn)) else { continue };
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
match c.out.try_send(Out::Bytes(b)) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
log::info!("live session: client {id} can't keep up; disconnecting it");
stuck.push(id);
}
Err(TrySendError::Disconnected(_)) => stuck.push(id),
}
}
}
for id in stuck {
if let Some(&conn) = self.ids.get(&id) {
if let Some(c) = self.conns.get(&conn) {
shutdown(&c.sock);
}
self.gone(conn);
}
}
}
/// What the host's own window should know.
fn to_local(&self, msg: &ServerMsg) {
match msg {
ServerMsg::PeerJoined(p) if p.client_id != self.local => self.inbox.push(Event::PeerJoined(p.clone())),
ServerMsg::PeerLeft { client_id } => self.inbox.push(Event::PeerLeft(*client_id)),
ServerMsg::Presence { client_id, presence } => self.inbox.presence(*client_id, presence.clone()),
ServerMsg::Changes { .. } | ServerMsg::Reject { .. } => self.inbox.push(Event::Sync(msg.clone())),
_ => {}
}
self.hosting.stop();
}
}

View File

@ -0,0 +1,842 @@
//! The hub every host runs, for one file (a session the app hosts) or many (`figcraft-server`):
//! a WebSocket listener, a reader and a writer thread per connection, and one hub thread that owns
//! a collaboration core [`Server`] per open file and routes its messages.
//!
//! What differs between the two lives behind a [`Backend`]: which hellos it admits and with what
//! access, where a file that isn't open comes from, what is kept of the changes (a server appends
//! them to its log; the app keeps nothing), and the managing connections a server answers. The
//! person hosting an app session takes part through the hub directly (no socket): their presence
//! and edits go in with [`Hosting::presence`] and [`Hosting::send`], what others do comes out
//! through their [`Inbox`].
//!
//! On top of the core the hub hands out colours and default names per file, throttles presence to
//! about 30 updates a second per person (latest wins), tells a newcomer its own name and colour (a
//! `PeerJoined` of itself, after its welcome) and where everyone already is, and disconnects
//! clients that send too much, too fast, or can't keep up. Whatever the servers say goes out only
//! after the backend has made what changed durable ([`Backend::flush`], once per burst of
//! messages), so a confirmed edit is never lost to a crash.
use std::collections::{BTreeMap, HashMap};
use std::io;
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, Hello, Manage, PROTOCOL, Presence, ServerMsg};
use figcraft_collab::{Outgoing, Server};
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
use tungstenite::protocol::frame::coding::CloseCode;
use tungstenite::protocol::{CloseFrame, Role, WebSocket};
use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::join::{ENDED, RESTARTING};
use crate::net::{Bucket, Duplex, NetLimits, config, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// How long a closing connection waits for the peer to hang up.
const LINGER: Duration = Duration::from_secs(2);
/// Most messages the hub handles before it sends what they produced (bounds the delay a burst
/// adds).
const BURST: usize = 256;
/// What a link lets a hello do.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Admission {
pub access: Access,
/// The link's id, when the backend has several per file (revoking it disconnects whoever
/// joined with it).
pub link: Option<String>,
}
/// An admin request's reply, and what the hub must do about connections it affects.
#[derive(Clone, Debug, PartialEq)]
pub struct Handled {
pub reply: AdminReply,
/// A link was revoked: `(file, link)`; whoever joined with it is disconnected.
pub revoked: Option<(String, String)>,
/// A file was removed: whoever is in it is disconnected and it is closed without saving.
pub removed: Option<String>,
}
impl From<AdminReply> for Handled {
fn from(reply: AdminReply) -> Self {
Self { reply, revoked: None, removed: None }
}
}
/// What everyone is told when a hub stops.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Closing {
/// The session is over (the app stopped hosting): final.
Ended,
/// The server is restarting: clients reconnect until it is back.
Restarting,
}
/// What the hub knows of an open file (for listings).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OpenFile {
/// People connected to it.
pub people: u32,
/// Its latest sequence number.
pub seq: u64,
}
/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it
/// from its own thread only.
pub trait Backend: Send + 'static {
/// A hello for `file` with `token`: what it may do, or why it may not join (the reason is
/// shown to the person joining; never echo the token).
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String>;
/// The session for a file that isn't open (after [`admit`](Self::admit) let someone in).
fn open(&mut self, file: &str) -> Result<Server, String>;
/// The hub handled a message for an open file: keep what changed (its server's journal).
fn changed(&mut self, _file: &str, _server: &mut Server) {}
/// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers
/// after this.
fn flush(&mut self) {}
/// Everyone left `file`: save it. True closes it (the next hello opens it again).
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
false
}
/// The hub is stopping: save the open files.
fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {}
/// A managing connection's key: whether it may manage files.
fn manage(&mut self, _key: &str) -> Result<(), String> {
Err("this session is hosted by an app: it has no files to manage".into())
}
/// A request on a managing connection; `open` tells about a file that is open.
fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
}
/// What everyone is told when the hub stops.
fn closing(&self) -> Closing;
}
/// A message for one connection's writer.
enum Out {
Bytes(Bytes),
Close(CloseCode, String),
}
enum HubIn {
/// A connection's first message (`Hello` or `Manage`).
Open {
conn: u64,
first: ClientMsg,
out: SyncSender<Out>,
sock: TcpStream,
},
Msg {
conn: u64,
msg: ClientMsg,
},
Gone {
conn: u64,
},
Local(Presence),
LocalMsg(ClientMsg),
/// Cut every connection without a word (tests: a network failure).
#[cfg(test)]
Cut,
Stop,
}
/// The person hosting an app session: in the file's session without a socket.
pub(crate) struct Local {
pub file: String,
pub server: Server,
/// Their client id (they joined `server` already).
pub id: u32,
pub inbox: Inbox,
}
fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result<JoinHandle<()>> {
std::thread::Builder::new().name(name.to_string()).spawn(f)
}
/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters.
pub(crate) fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect()
}
/// A running hub: its listener, connections and hub thread. Dropping it stops it.
pub struct Hosting {
addr: SocketAddr,
hub: Sender<HubIn>,
stop: Arc<AtomicBool>,
open: Arc<AtomicUsize>,
hub_thread: Option<JoinHandle<()>>,
accept_thread: Option<JoinHandle<()>>,
}
impl Hosting {
/// Serve `backend`'s files on `listener`.
pub fn serve<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result<Hosting> {
Self::start(listener, backend, limits, None)
}
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
let addr = listener.local_addr()?;
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new() };
if let Some(l) = local {
let mut room = Room::new(l.server);
room.local = Some((l.id, l.inbox));
hub.rooms.insert(l.file.clone(), room);
hub.local = Some(l.file);
}
let (hub_tx, hub_rx) = channel();
let stop = Arc::new(AtomicBool::new(false));
let open = Arc::new(AtomicUsize::new(0));
let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?;
let accept_thread = {
let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone());
spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits))
};
let accept_thread = match accept_thread {
Ok(t) => t,
Err(e) => {
let _ = hub_tx.send(HubIn::Stop);
let _ = hub_thread.join();
return Err(e);
}
};
log::info!("live session: listening on {addr}");
Ok(Hosting { addr, hub: hub_tx, stop, open, hub_thread: Some(hub_thread), accept_thread: Some(accept_thread) })
}
/// Where it listens.
pub fn addr(&self) -> SocketAddr {
self.addr
}
/// Open connections (joined or not).
pub fn connections(&self) -> usize {
self.open.load(Ordering::Relaxed)
}
/// The hosting person's presence (app sessions).
pub(crate) fn presence(&self, p: Presence) {
let _ = self.hub.send(HubIn::Local(p));
}
/// A message from the hosting person's sync client (their edits).
pub(crate) fn send(&self, msg: ClientMsg) {
let _ = self.hub.send(HubIn::LocalMsg(msg));
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
let _ = self.hub.send(HubIn::Cut);
}
/// Stop: the backend saves what's open, everyone is told and disconnected, the port is
/// released.
pub fn stop(&mut self) {
if self.stop.swap(true, Ordering::SeqCst) {
return;
}
let _ = self.hub.send(HubIn::Stop);
if let Some(t) = self.hub_thread.take() {
let _ = t.join();
}
// The accept loop checks the flag after each connection: wake it with one.
let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr };
let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok();
if let Some(t) = self.accept_thread.take() {
if woke {
let _ = t.join();
} else {
log::warn!("live session: couldn't wake the accept loop; it ends with the next connection");
}
}
// Readers end once their writers have closed the sockets.
let deadline = Instant::now() + Duration::from_secs(2);
while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
log::info!("live session: stopped listening on {}", self.addr);
}
}
impl Drop for Hosting {
fn drop(&mut self) {
self.stop();
}
}
/// The loopback address of `addr`'s family.
fn loopback(addr: SocketAddr) -> std::net::IpAddr {
if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() }
}
/// Counts an open connection until dropped.
struct Open(Arc<AtomicUsize>);
impl Drop for Open {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits) {
let mut next = 0u64;
for stream in listener.incoming() {
if stop.load(Ordering::SeqCst) {
break;
}
let stream = match stream {
Ok(s) => s,
Err(e) => {
// Out of sockets or similar: don't spin.
log::warn!("live session: accept failed: {e}");
std::thread::sleep(Duration::from_millis(50));
continue;
}
};
if open.load(Ordering::Relaxed) >= limits.max_connections {
log::info!("live session: too many connections; refusing one");
continue;
}
open.fetch_add(1, Ordering::Relaxed);
let guard = Open(open.clone());
next += 1;
let (conn, hub) = (next, hub.clone());
if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard)) {
log::warn!("live session: can't start a connection thread: {e}");
}
}
}
/// A close reason that fits a WebSocket close frame (123 bytes).
fn reason(s: &str) -> String {
let mut end = s.len().min(123);
while !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or_default().to_string()
}
fn encode(msg: &ServerMsg) -> Option<Bytes> {
match message::encode(msg) {
Ok(b) => Some(Bytes::from(b)),
Err(e) => {
log::warn!("live session: can't encode a message: {e}");
None
}
}
}
/// Queue `Refused {reason}` and a close for one connection.
fn refuse(out: &SyncSender<Out>, why: &str) {
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
let _ = out.try_send(Out::Bytes(b));
}
let _ = out.try_send(Out::Close(CloseCode::Policy, reason(why)));
}
/// Read the next binary message (skipping pings), or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<ClientMsg>, &'static str> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => message::decode::<ClientMsg>(&b, max).map(Some).map_err(|_| "that message couldn't be read"),
Ok(Message::Text(_)) => Err("only binary messages are spoken here"),
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
Ok(Message::Close(_)) => Ok(None),
Err(tungstenite::Error::Capacity(_)) => Err("message too large"),
Err(_) => Ok(None),
};
}
}
/// One connection's reader (the writer runs on its own thread).
fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _open: Open) {
let _ = stream.set_nodelay(true);
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
let Ok((rd, wr, sock)) = Duplex::split(stream) else { return };
let cfg = config(limits.max_client_message);
let mut ws = match tungstenite::accept_with_config(rd, Some(config(limits.max_hello))) {
Ok(ws) => ws,
Err(e) => {
log::debug!("live session: handshake failed: {e}");
shutdown(&sock);
return;
}
};
let (out, rx) = sync_channel::<Out>(limits.queue.max(4));
let writer =
match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) {
Ok(Ok(t)) => t,
_ => {
shutdown(&sock);
return;
}
};
let end = |why: Option<&str>| {
if let Some(why) = why {
refuse(&out, why);
} else {
let _ = out.try_send(Out::Close(CloseCode::Normal, String::new()));
}
};
match next_message(&mut ws, limits.max_hello) {
Ok(Some(first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_)))) => {
ws.set_config(|c| *c = cfg);
let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, first, out: out.clone(), sock: s }).is_ok());
if opened && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() {
read_loop(&mut ws, conn, hub, limits, &end);
let _ = hub.send(HubIn::Gone { conn });
}
}
Ok(Some(_)) => end(Some("say hello first")),
Ok(None) => {}
Err(why) => end(Some(why)),
}
linger(&mut ws);
drop(out);
// The writer closes the socket once it has sent what's queued (a refusal, a close).
let _ = writer.join();
shutdown(&sock);
}
fn read_loop(ws: &mut WebSocket<Duplex>, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, end: &dyn Fn(Option<&str>)) {
let mut bucket = Bucket::new(limits.rate, limits.burst);
loop {
match next_message(ws, limits.max_client_message) {
Ok(Some(msg)) => {
if !bucket.take(Instant::now()) {
log::info!("live session: connection {conn} sends too much; disconnecting it");
return end(Some("too many messages"));
}
if hub.send(HubIn::Msg { conn, msg }).is_err() {
return;
}
}
Ok(None) => return end(None),
Err(why) => return end(Some(why)),
}
}
}
fn write_loop(mut ws: WebSocket<Duplex>, rx: &Receiver<Out>, sock: &TcpStream) {
let mut close = Some((CloseCode::Normal, String::new()));
while let Ok(o) = rx.recv() {
match o {
Out::Bytes(b) => {
if ws.send(Message::Binary(b)).is_err() {
close = None;
break;
}
}
Out::Close(code, why) => {
close = Some((code, why));
break;
}
}
}
if let Some((code, why)) = close {
// Close politely: the close frame, then no more writing; the reader drains what the peer
// still sends until it hangs up (closing on unread data would reset the connection and
// could lose the close frame), or until the linger runs out.
let _ = ws.close(Some(CloseFrame { code, reason: why.into() }));
let _ = sock.shutdown(std::net::Shutdown::Write);
let until = Instant::now() + LINGER;
while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
if Instant::now() >= until {
break;
}
}
}
shutdown(sock);
}
/// Read and drop what the peer still sends after the close, until it hangs up (at most
/// [`LINGER`]).
fn linger(ws: &mut WebSocket<Duplex>) {
let until = Instant::now() + LINGER;
while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() {
match ws.read() {
Ok(Message::Close(_)) => break,
Ok(_) => {}
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
Err(_) => break,
}
}
}
/// What a connection is for.
enum Kind {
/// In `file`'s session as client `id`, through link `link`.
Joined { file: String, id: u32, link: Option<String> },
/// Managing the server's files.
Managing,
}
/// A joined or managing connection.
struct Conn {
out: SyncSender<Out>,
sock: TcpStream,
kind: Kind,
}
/// One open file's session.
struct Room {
server: Server,
/// Client id → connection.
ids: BTreeMap<u32, u64>,
throttles: BTreeMap<u32, Throttle<Presence>>,
/// Everyone's latest presence (for newcomers).
latest: BTreeMap<u32, Presence>,
/// Default names handed out ("Guest 1", "Guest 2"…).
guests: u32,
/// The person hosting (app sessions): their client id and inbox.
local: Option<(u32, Inbox)>,
}
impl Room {
fn new(server: Server) -> Self {
Self { server, ids: BTreeMap::new(), throttles: BTreeMap::new(), latest: BTreeMap::new(), guests: 0, local: None }
}
/// Nobody is in it.
fn empty(&self) -> bool {
self.ids.is_empty() && self.local.is_none()
}
}
/// Owns the open files' servers and routes their messages.
struct Hub<B> {
backend: B,
rooms: HashMap<String, Room>,
conns: HashMap<u64, Conn>,
/// The file the person hosting is in (app sessions).
local: Option<String>,
/// What the servers said, sent once the backend made what changed durable.
outbox: Vec<(String, Outgoing)>,
}
impl<B: Backend> Hub<B> {
fn run(mut self, rx: &Receiver<HubIn>) {
'run: loop {
let due = earliest(self.rooms.values().flat_map(|r| r.throttles.values().map(Throttle::due)));
let first = match due {
Some(d) => match rx.recv_timeout(d.saturating_duration_since(Instant::now())) {
Ok(m) => Some(m),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => Some(HubIn::Stop),
},
None => Some(rx.recv().unwrap_or(HubIn::Stop)),
};
// A burst: handle what's waiting, then make it durable and answer.
let mut next = first;
let mut handled = 0;
while let Some(m) = next.take() {
if matches!(m, HubIn::Stop) {
break 'run;
}
self.handle(m);
handled += 1;
if handled < BURST {
next = rx.try_recv().ok();
}
}
self.flush_due(Instant::now());
self.send_out();
}
self.send_out();
self.backend.stop(self.rooms.iter_mut().map(|(f, r)| (f.as_str(), &mut r.server)).collect());
let (code, why) = match self.backend.closing() {
Closing::Ended => (CloseCode::Away, ENDED),
Closing::Restarting => (CloseCode::Restart, RESTARTING),
};
for c in self.conns.values() {
if c.out.try_send(Out::Close(code, why.into())).is_err() {
shutdown(&c.sock);
}
}
}
fn handle(&mut self, m: HubIn) {
match m {
HubIn::Open { conn, first: ClientMsg::Hello(hello), out, sock } => self.join(conn, hello, out, sock),
HubIn::Open { conn, first: ClientMsg::Manage(m), out, sock } => self.manage(conn, &m, out, sock),
HubIn::Open { out, .. } => refuse(&out, "say hello first"),
HubIn::Msg { conn, msg } => match self.conns.get(&conn).map(|c| &c.kind) {
Some(Kind::Joined { file, id, .. }) => {
let (file, id) = (file.clone(), *id);
self.client_message(&file, id, msg);
}
Some(Kind::Managing) => match msg {
ClientMsg::Admin(req) => self.admin(conn, req),
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
},
None => {}
},
HubIn::Gone { conn } => self.gone(conn),
HubIn::Local(p) | HubIn::LocalMsg(ClientMsg::Presence(p)) => {
if let Some((file, id)) = self.local_id() {
self.presence(&file, id, p);
}
}
HubIn::LocalMsg(msg) => {
if let Some((file, id)) = self.local_id() {
self.client_message(&file, id, msg);
}
}
#[cfg(test)]
HubIn::Cut => {
for c in self.conns.values() {
shutdown(&c.sock);
}
}
HubIn::Stop => {}
}
}
/// The hosting person's file and client id.
fn local_id(&self) -> Option<(String, u32)> {
let file = self.local.clone()?;
let id = self.rooms.get(&file)?.local.as_ref()?.0;
Some((file, id))
}
/// A message from client `id` of `file`.
fn client_message(&mut self, file: &str, id: u32, msg: ClientMsg) {
if let ClientMsg::Presence(p) = msg {
return self.presence(file, id, p);
}
let Some(room) = self.rooms.get_mut(file) else { return };
let outs = room.server.receive(id, msg);
self.backend.changed(file, &mut room.server);
self.queue(file, outs);
}
fn queue(&mut self, file: &str, outs: Vec<Outgoing>) {
self.outbox.extend(outs.into_iter().map(|o| (file.to_string(), o)));
}
fn join(&mut self, conn: u64, mut hello: Hello, out: SyncSender<Out>, sock: TcpStream) {
let admission = match self.backend.admit(&hello.file, &hello.token) {
Ok(a) => a,
Err(why) => return refuse(&out, &why),
};
// The backend checked the secret: the sessions don't keep it.
hello.token.clear();
let file = hello.file.clone();
if !self.rooms.contains_key(&file) {
match self.backend.open(&file) {
Ok(server) => {
self.rooms.insert(file.clone(), Room::new(server));
}
Err(why) => return refuse(&out, &why),
}
}
let Some(room) = self.rooms.get_mut(&file) else { return };
// Colours: the first one nobody has.
let taken: Vec<&str> = room.server.peers().map(|p| p.color.as_str()).collect();
let color = PEOPLE.iter().find(|c| !taken.contains(c)).copied().unwrap_or(PEOPLE[taken.len() % PEOPLE.len()]);
hello.color = color.to_string();
hello.name = match clean_name(&hello.name) {
n if n.is_empty() => {
room.guests += 1;
format!("Guest {}", room.guests)
}
n => n,
};
match room.server.join_as(hello, admission.access) {
Ok((id, outs)) => {
// A guest back after a drop keeps its id: its old connection, if the hub hasn't
// noticed it's gone yet, is closed without the guest leaving.
let old = room.ids.insert(id, conn).filter(|old| *old != conn);
self.backend.changed(&file, &mut room.server);
// The newcomer learns how it is shown, then where everyone is.
let mut more = outs;
if let Some(me) = room.server.peers().find(|p| p.client_id == id) {
more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone())));
}
more.extend(room.latest.iter().map(|(&from, p)| Outgoing::To(id, ServerMsg::Presence { client_id: from, presence: p.clone() })));
if let Some(c) = old.and_then(|old| self.conns.remove(&old)) {
shutdown(&c.sock);
}
self.conns.insert(conn, Conn { out, sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link } });
self.queue(&file, more);
}
Err(ServerMsg::Refused { reason }) => {
refuse(&out, &reason);
self.close_if_empty(&file);
}
Err(_) => {
refuse(&out, "refused");
self.close_if_empty(&file);
}
}
}
fn manage(&mut self, conn: u64, m: &Manage, out: SyncSender<Out>, sock: TcpStream) {
if m.protocol != PROTOCOL {
return refuse(&out, &format!("protocol {} isn't supported (this server speaks {PROTOCOL})", m.protocol));
}
if let Err(why) = self.backend.manage(&m.key) {
return refuse(&out, &why);
}
log::info!("live session: connection {conn} manages files");
self.conns.insert(conn, Conn { out, sock, kind: Kind::Managing });
self.answer(conn, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
}
fn admin(&mut self, conn: u64, req: Admin) {
let rooms = &self.rooms;
let open = |file: &str| {
rooms.get(file).map(|r| OpenFile { people: u32::try_from(r.server.peers().count()).unwrap_or(u32::MAX), seq: r.server.seq() })
};
let handled = self.backend.admin(req, &open);
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
if let Some((file, link)) = handled.revoked {
let users: Vec<u64> = self
.conns
.iter()
.filter(|(_, c)| matches!(&c.kind, Kind::Joined { file: f, link: Some(l), .. } if *f == file && *l == link))
.map(|(k, _)| *k)
.collect();
self.kick(&users, "the link you joined with was revoked");
}
if let Some(file) = handled.removed {
let users: Vec<u64> =
self.conns.iter().filter(|(_, c)| matches!(&c.kind, Kind::Joined { file: f, .. } if *f == file)).map(|(k, _)| *k).collect();
// Closed without saving: the file is gone.
self.rooms.remove(&file);
self.kick(&users, "this file was removed from the server");
}
}
/// Send one message to one connection now (managing connections' answers).
fn answer(&mut self, conn: u64, msg: &ServerMsg) {
let Some(c) = self.conns.get(&conn) else { return };
let sent = encode(msg).is_some_and(|b| c.out.try_send(Out::Bytes(b)).is_ok());
if !sent {
shutdown(&c.sock);
self.gone(conn);
}
}
/// Refuse connections with `why` and drop them at once (nothing more they send counts).
fn kick(&mut self, conns: &[u64], why: &str) {
for conn in conns {
if let Some(c) = self.conns.get(conn) {
refuse(&c.out, why);
}
self.gone(*conn);
}
}
fn gone(&mut self, conn: u64) {
let Some(c) = self.conns.remove(&conn) else { return };
let Kind::Joined { file, id, .. } = c.kind else { return };
let Some(room) = self.rooms.get_mut(&file) else { return };
if room.ids.get(&id) == Some(&conn) {
room.ids.remove(&id);
}
room.throttles.remove(&id);
room.latest.remove(&id);
let outs = room.server.leave(id);
self.queue(&file, outs);
self.close_if_empty(&file);
}
/// Nobody is in `file`: the backend saves it and may close it.
fn close_if_empty(&mut self, file: &str) {
let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return };
if self.backend.idle(file, &mut room.server) {
self.rooms.remove(file);
}
}
fn presence(&mut self, file: &str, id: u32, p: Presence) {
let Some(room) = self.rooms.get_mut(file) else { return };
let p = sanitize(p);
room.latest.insert(id, p.clone());
if let Some(p) = room.throttles.entry(id).or_insert_with(|| Throttle::new(PRESENCE_INTERVAL)).offer(p, Instant::now()) {
let outs = room.server.receive(id, ClientMsg::Presence(p));
self.queue(file, outs);
}
}
fn flush_due(&mut self, now: Instant) {
let mut outs = Vec::new();
for (file, room) in &mut self.rooms {
let due: Vec<(u32, Presence)> = room.throttles.iter_mut().filter_map(|(id, t)| t.take_due(now).map(|p| (*id, p))).collect();
for (id, p) in due {
outs.extend(room.server.receive(id, ClientMsg::Presence(p)).into_iter().map(|o| (file.clone(), o)));
}
}
self.outbox.extend(outs);
}
/// Make what changed durable, then send what the servers said; connections that can't keep up
/// are dropped (which may say more: that they left).
fn send_out(&mut self) {
self.backend.flush();
while !self.outbox.is_empty() {
let mut stuck = Vec::new();
for (file, o) in std::mem::take(&mut self.outbox) {
let Some(room) = self.rooms.get(&file) else { continue };
let (to, msg): (Vec<u32>, ServerMsg) = match o {
Outgoing::To(id, m) => (vec![id], m),
Outgoing::All(m) => (room.server.peers().map(|p| p.client_id).collect(), m),
Outgoing::Others(x, m) => (room.server.peers().map(|p| p.client_id).filter(|id| *id != x).collect(), m),
};
let mut bytes = None;
for id in to {
if let Some((local, inbox)) = room.local.as_ref().filter(|(l, _)| *l == id) {
to_local(inbox, *local, &msg);
continue;
}
let Some((&conn, c)) = room.ids.get(&id).and_then(|conn| self.conns.get_key_value(conn)) else { continue };
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
match c.out.try_send(Out::Bytes(b)) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
log::info!("live session: client {id} can't keep up; disconnecting it");
stuck.push(conn);
}
Err(TrySendError::Disconnected(_)) => stuck.push(conn),
}
}
}
for conn in stuck {
if let Some(c) = self.conns.get(&conn) {
shutdown(&c.sock);
}
self.gone(conn);
}
}
}
}
/// What the hosting person's window should know.
fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) {
match msg {
ServerMsg::PeerJoined(p) if p.client_id != local => inbox.push(Event::PeerJoined(p.clone())),
ServerMsg::PeerLeft { client_id } => inbox.push(Event::PeerLeft(*client_id)),
ServerMsg::Presence { client_id, presence } => inbox.presence(*client_id, presence.clone()),
ServerMsg::Changes { .. } | ServerMsg::Reject { .. } => inbox.push(Event::Sync(msg.clone())),
_ => {}
}
}

View File

@ -1,37 +1,61 @@
//! Invite links: `figcraft://join?host=<ip:port>&file=<id>&token=<secret>`, or the same session as a
//! plain WebSocket URL `ws://<ip:port>/?file=<id>&token=<secret>`.
//! Invite links and server addresses.
//!
//! An invite (or share) link names a session's endpoint, its file and a token:
//! `figcraft://join?host=<host:port>&file=<id>&token=<secret>` for a plain `ws://` endpoint at `/`
//! (what the desktop app hosts), `figcraft://join?server=<ws(s)://host[:port][/path]>&file=…&token=…`
//! for any endpoint (a `figcraft-server` behind a TLS reverse proxy, under a path), or the same
//! session as a WebSocket URL `ws(s)://host[:port][/path]?file=<id>&token=<secret>`.
//!
//! The token never travels in the WebSocket request (a proxy's logs would keep it): the client
//! connects to `ws://<host>/` and sends the token in its `Hello`. Errors never echo the link.
//! connects to the endpoint and sends the token in its `Hello`. A server address for managing
//! files carries the server's key the same way (`ws://host:port/?key=<key>`). Errors never echo a
//! link or an address.
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Endpoint {
/// `host:port` (an IP address, `[v6]:port`, or a host name).
pub host: String,
/// The path it answers on: `/`, or a reverse proxy's prefix (`/figcraft/`).
pub path: String,
/// `wss://`: TLS (a reverse proxy in front of the server).
pub secure: bool,
}
/// What an invite link names.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Invite {
/// `host:port` (an IP address, `[v6]:port`, or a host name).
pub host: String,
/// The shared file's session id.
pub server: Endpoint,
/// The shared file's id.
pub file: String,
/// The session's secret.
/// The link's secret.
pub token: String,
}
/// Longest link accepted (anything longer isn't one of ours).
const MAX_LINK: usize = 2048;
impl Invite {
/// The `figcraft://join?…` link.
pub fn link(&self) -> String {
format!("figcraft://join?host={}&file={}&token={}", self.host, self.file, self.token)
const NOT: &str = "that's not a live session invite: expected figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…";
impl Endpoint {
/// A plain `ws://host/` endpoint.
pub fn plain(host: impl Into<String>) -> Self {
Self { host: host.into(), path: "/".into(), secure: false }
}
/// The plain WebSocket URL of the same session.
/// `ws://` at `/`: an invite can name it as `host=…`.
fn is_plain(&self) -> bool {
!self.secure && self.path == "/"
}
/// The WebSocket URL to connect to (no secrets in it).
pub fn url(&self) -> String {
format!("ws://{}/?file={}&token={}", self.host, self.file, self.token)
format!("{}://{}{}", if self.secure { "wss" } else { "ws" }, self.host, self.path)
}
/// What the client connects to (no secrets in it).
pub fn endpoint(&self) -> String {
format!("ws://{}/", self.host)
/// How the address is shown: `host:port` for a plain endpoint, else its URL.
pub fn display(&self) -> String {
if self.is_plain() { self.host.clone() } else { self.url() }
}
/// The host is this machine (a loopback address or `localhost`).
@ -42,49 +66,133 @@ impl Invite {
}
}
/// Read an invite link (either form; surrounding spaces, quotes or angle brackets are fine).
/// Read `ws(s)://host[:port][/path][?query]` (a missing port is the scheme's: 80 or 443): the
/// endpoint and the query's decoded pairs. Errors say what's wrong without echoing `url`.
pub fn parse(url: &str) -> Result<(Endpoint, Vec<(String, String)>), String> {
const BAD: &str = "that's not a server address: expected ws://host:port/ or wss://host/";
let url = clean(url);
if url.len() > MAX_LINK {
return Err(BAD.into());
}
let (scheme, rest) = url.split_once("://").ok_or(BAD)?;
let secure = if scheme.eq_ignore_ascii_case("ws") {
false
} else if scheme.eq_ignore_ascii_case("wss") {
true
} else {
return Err(BAD.into());
};
let (before_query, query) = rest.split_once('?').unwrap_or((rest, ""));
let (authority, path) = match before_query.find('/') {
Some(i) => before_query.split_at(i),
None => (before_query, "/"),
};
let host = with_port(authority, if secure { 443 } else { 80 });
if !valid_host(&host) {
return Err("the address's host isn't a host name or IP address with a port (like 192.168.1.20:47900)".into());
}
if !valid_path(path) {
return Err("the address's path has characters a server path can't have".into());
}
let mut path = path.to_string();
if !path.ends_with('/') {
path.push('/');
}
Ok((Endpoint { host, path, secure }, pairs(query)?))
}
}
impl Invite {
/// An invite for a plain `ws://host/` endpoint.
pub fn new(host: impl Into<String>, file: impl Into<String>, token: impl Into<String>) -> Self {
Self { server: Endpoint::plain(host), file: file.into(), token: token.into() }
}
/// The `figcraft://join?…` link.
pub fn link(&self) -> String {
if self.server.is_plain() {
format!("figcraft://join?host={}&file={}&token={}", self.server.host, self.file, self.token)
} else {
format!("figcraft://join?server={}&file={}&token={}", self.server.url(), self.file, self.token)
}
}
/// The same session as a WebSocket URL.
pub fn url(&self) -> String {
format!("{}?file={}&token={}", self.server.url(), self.file, self.token)
}
/// What the client connects to (no secrets in it).
pub fn endpoint(&self) -> String {
self.server.url()
}
/// The host is this machine.
pub fn loopback(&self) -> bool {
self.server.loopback()
}
/// Read an invite link (any form; surrounding spaces, quotes or angle brackets are fine).
pub fn parse(link: &str) -> Result<Invite, String> {
const NOT: &str = "that's not a live session invite: expected figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…";
let link = link.trim().trim_matches(|c| matches!(c, '<' | '>' | '"' | '\''));
let link = clean(link);
if link.len() > MAX_LINK {
return Err(NOT.into());
}
let (scheme, rest) = link.split_once("://").ok_or(NOT)?;
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
let mut host = None;
let mut file = None;
let mut token = None;
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let v = percent_decode(v).ok_or(NOT)?;
match k {
"host" => host = Some(v),
"file" => file = Some(v),
"token" => token = Some(v),
_ => {}
}
}
if scheme.eq_ignore_ascii_case("figcraft") {
let (server, query) = if scheme.eq_ignore_ascii_case("figcraft") {
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
if !path.trim_end_matches('/').eq_ignore_ascii_case("join") {
return Err(NOT.into());
}
} else if scheme.eq_ignore_ascii_case("ws") {
// ws://host:port/… : the host is the authority.
host = Some(path.split('/').next().unwrap_or("").to_string());
} else if scheme.eq_ignore_ascii_case("wss") {
return Err("secure (wss://) live sessions need a server behind TLS, which comes with the standalone server".into());
let query = pairs(query).map_err(|_| NOT)?;
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
let server = match (get("server"), get("host")) {
(Some(url), _) => Endpoint::parse(&url).map(|(e, _)| e).map_err(|_| "the invite link's server isn't a ws:// or wss:// address")?,
(None, Some(host)) if valid_host(&host) => Endpoint::plain(host),
_ => return Err("the invite link's host isn't an address with a port (like 192.168.1.20:47900)".into()),
};
(server, query)
} else if scheme.eq_ignore_ascii_case("ws") || scheme.eq_ignore_ascii_case("wss") {
Endpoint::parse(link).map_err(|e| if e.contains("host") { e } else { NOT.into() })?
} else {
return Err(NOT.into());
}
let host = host.filter(|h| valid_host(h)).ok_or("the invite link's host isn't an address with a port (like 192.168.1.20:47900)")?;
let file = file.filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
let token = token.filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
Ok(Invite { host, file, token })
};
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
Ok(Invite { server, file, token })
}
}
/// Without surrounding spaces, quotes or angle brackets.
fn clean(s: &str) -> &str {
s.trim().trim_matches(|c| matches!(c, '<' | '>' | '"' | '\''))
}
/// A query's `key=value` pairs, decoded.
fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
query
.split('&')
.filter(|p| !p.is_empty())
.map(|pair| {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
Ok((k.to_string(), percent_decode(v).ok_or("a broken %-escape")?))
})
.collect()
}
/// `authority` with `default` as its port when it has none.
fn with_port(authority: &str, default: u16) -> String {
let has_port = match authority.rsplit_once(':') {
// `[v6]` alone has colons inside the brackets only.
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
None => false,
};
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
}
/// Ids and tokens are URL-safe base64 / hex characters.
fn valid_id(s: &str, min: usize, max: usize) -> bool {
pub(crate) fn valid_id(s: &str, min: usize, max: usize) -> bool {
(min..=max).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
@ -100,6 +208,11 @@ fn valid_host(s: &str) -> bool {
&& !name.starts_with(['-', '.'])
}
/// A path of unreserved characters and slashes, starting with `/`.
fn valid_path(p: &str) -> bool {
p.starts_with('/') && p.len() <= 256 && p.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.' | b'~'))
}
/// `%XX` escapes decoded (UTF-8); `None` for broken escapes.
fn percent_decode(s: &str) -> Option<String> {
let bytes = s.as_bytes();

View File

@ -1,14 +1,14 @@
//! Invite links: both forms read back, bad links are refused without echoing them, and the
//! parser survives anything.
//! Invite links and server addresses: every form reads back, bad links are refused without
//! echoing them, and the parsers survive anything.
use proptest::prelude::*;
use crate::invite::Invite;
use crate::invite::{Endpoint, Invite};
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
fn invite() -> Invite {
Invite { host: "192.168.1.20:47900".into(), file: "0123456789abcdef".into(), token: TOKEN.into() }
Invite::new("192.168.1.20:47900", "0123456789abcdef", TOKEN)
}
#[test]
@ -23,17 +23,63 @@ fn both_forms_read_back() {
assert_eq!(Invite::parse(&format!("figcraft://join?host=192.168.1.20%3A47900&file=0123456789abcdef&token={TOKEN}")), Ok(i.clone()));
// The secret never travels in the WebSocket request.
assert_eq!(i.endpoint(), "ws://192.168.1.20:47900/");
assert_eq!(i.server.display(), "192.168.1.20:47900");
}
#[test]
fn hosts_can_be_v6_or_names() {
let v6 = Invite { host: "[::1]:47900".into(), ..invite() };
let v6 = Invite::new("[::1]:47900", "0123456789abcdef", TOKEN);
assert_eq!(Invite::parse(&v6.link()), Ok(v6.clone()));
assert_eq!(Invite::parse(&v6.url()), Ok(v6));
let name = Invite { host: "studio.local:47900".into(), ..invite() };
assert_eq!(Invite::parse(&v6.url()), Ok(v6.clone()));
assert!(v6.loopback());
let name = Invite::new("studio.local:47900", "0123456789abcdef", TOKEN);
assert_eq!(Invite::parse(&name.link()), Ok(name));
}
#[test]
fn server_links_carry_tls_and_a_path() {
// A server behind a TLS reverse proxy, under a path: the server= form.
let i = Invite { server: Endpoint { host: "figcraft.example.com:443".into(), path: "/live/".into(), secure: true }, ..invite() };
assert_eq!(i.link(), format!("figcraft://join?server=wss://figcraft.example.com:443/live/&file=0123456789abcdef&token={TOKEN}"));
assert_eq!(i.url(), format!("wss://figcraft.example.com:443/live/?file=0123456789abcdef&token={TOKEN}"));
assert_eq!(i.endpoint(), "wss://figcraft.example.com:443/live/");
assert_eq!(i.server.display(), "wss://figcraft.example.com:443/live/");
assert_eq!(Invite::parse(&i.link()), Ok(i.clone()));
assert_eq!(Invite::parse(&i.url()), Ok(i.clone()));
// The scheme's port when there's none, and a path without its last slash.
assert_eq!(Invite::parse(&format!("wss://figcraft.example.com/live?file=0123456789abcdef&token={TOKEN}")), Ok(i.clone()));
assert_eq!(
Invite::parse(&format!("figcraft://join?server=wss%3A%2F%2Ffigcraft.example.com%2Flive%2F&file=0123456789abcdef&token={TOKEN}")),
Ok(i)
);
// A plain server at / is the same as an app's invite.
let plain = Invite::parse(&format!("figcraft://join?server=ws://10.0.0.5:47900&file=0123456789abcdef&token={TOKEN}")).unwrap();
assert_eq!(plain, Invite::new("10.0.0.5:47900", "0123456789abcdef", TOKEN));
assert!(plain.link().starts_with("figcraft://join?host=10.0.0.5:47900&"));
}
#[test]
fn server_addresses_with_a_key() {
let (e, q) = Endpoint::parse("ws://127.0.0.1:47900/?key=KeyKeyKeyKeyKeyKeyKey0").unwrap();
assert_eq!(e, Endpoint::plain("127.0.0.1:47900"));
assert_eq!(q, vec![("key".to_string(), "KeyKeyKeyKeyKeyKeyKey0".to_string())]);
let (e, q) = Endpoint::parse(" wss://example.org ").unwrap();
assert_eq!(e, Endpoint { host: "example.org:443".into(), path: "/".into(), secure: true });
assert!(q.is_empty());
let secret = "SuperSecretToken123456";
for bad in [
format!("http://example.org/?key={secret}"),
format!("ws://exa mple.org/?key={secret}"),
format!("ws://example.org:0/?key={secret}"),
format!("ws://example.org/a/?key={secret}%zz"),
format!("ws://example.org/<x>/?key={secret}"),
format!("example.org?key={secret}"),
] {
let e = Endpoint::parse(&bad).expect_err(&bad);
assert!(!e.contains(secret), "{e}");
}
}
#[test]
fn bad_links_are_refused_without_echoing_them() {
let secret = "SuperSecretToken123456";
@ -49,7 +95,8 @@ fn bad_links_are_refused_without_echoing_them() {
"figcraft://join?host=10.0.0.1:47900&file=abc&token=short".into(),
format!("figcraft://join?host=10.0.0.1:47900&file=a/b&token={secret}"),
format!("figcraft://join?host=10.0.0.1:47900&file=abc&token={secret}%zz"),
format!("wss://10.0.0.1:47900/?file=abc&token={secret}"),
format!("figcraft://join?server=http://10.0.0.1:47900/&file=abc&token={secret}"),
format!("wss://10.0.0.1:47900/?file=abc&token=short{secret}!"),
format!("figcraft://join?host=10.0.0.1:47900&file=abc&token={}", "x".repeat(3000)),
] {
let e = Invite::parse(&bad).expect_err(&bad);
@ -59,15 +106,19 @@ fn bad_links_are_refused_without_echoing_them() {
proptest! {
#[test]
fn the_parser_survives_anything(s in ".{0,300}") {
fn the_parsers_survive_anything(s in ".{0,300}") {
let _ = Invite::parse(&s);
let _ = Invite::parse(&format!("figcraft://join?{s}"));
let _ = Invite::parse(&format!("figcraft://join?server={s}"));
let _ = Invite::parse(&format!("ws://{s}"));
let _ = Invite::parse(&format!("wss://{s}"));
let _ = Endpoint::parse(&s);
let _ = Endpoint::parse(&format!("ws://{s}"));
}
#[test]
fn valid_invites_round_trip(a in 1u8..255, port in 1u16.., file in "[a-f0-9]{1,64}", token in "[A-Za-z0-9_-]{16,128}") {
let i = Invite { host: format!("10.0.0.{a}:{port}"), file, token };
fn valid_invites_round_trip(a in 1u8..255, port in 1u16.., file in "[a-f0-9]{1,64}", token in "[A-Za-z0-9_-]{16,128}", path in "(/[a-z0-9]{1,8}){0,3}", secure: bool) {
let i = Invite { server: Endpoint { host: format!("10.0.0.{a}:{port}"), path: format!("{path}/"), secure }, file, token };
prop_assert_eq!(Invite::parse(&i.link()), Ok(i.clone()));
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
}

View File

@ -16,10 +16,14 @@ use crate::limits::NetLimits;
/// What the host tells everyone when it stops (the WebSocket close reason).
pub const ENDED: &str = "The host ended the session";
/// WebSocket close codes the host uses: it ended the session (going away), or it refused you
/// (policy).
/// What a server tells everyone when it stops (they reconnect once it is back).
pub const RESTARTING: &str = "The server is restarting";
/// WebSocket close codes hosts use: the app's session ended (going away), you were refused
/// (policy), or the server is restarting (service restart: try again).
pub(crate) const CLOSE_AWAY: u16 = 1001;
pub(crate) const CLOSE_POLICY: u16 = 1008;
pub(crate) const CLOSE_RESTART: u16 = 1012;
/// How a guest connects and reconnects.
#[derive(Clone, Debug)]
@ -72,6 +76,7 @@ pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
match code {
CLOSE_AWAY => Outcome::Ended(if reason.is_empty() { ENDED.to_string() } else { reason.to_string() }),
CLOSE_POLICY => Outcome::Refused(reason.to_string()),
CLOSE_RESTART => Outcome::Retry(if reason.is_empty() { RESTARTING.to_string() } else { reason.to_string() }),
_ => Outcome::Retry("the host closed the connection".into()),
}
}
@ -143,7 +148,7 @@ pub(crate) enum Taken {
/// in answer must go out on this connection.
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
match m {
ServerMsg::Welcome { session, client_id, seq, ref peers, .. } => {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => {
if let Ok(mut r) = resume.lock() {
*r = Some(Resume { session, client_id, seq });
}
@ -159,8 +164,9 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
pages: Vec::new(),
link: inv.link(),
url: inv.url(),
address: inv.host.clone(),
address: inv.server.display(),
network: !inv.loopback(),
access,
};
seen.peers = peers.clone();
seen.info = Some(info.clone());
@ -201,7 +207,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
inbox.push(Event::Sync(m));
}
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
ServerMsg::Pong => {}
// Answers for managing connections: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) => {}
}
Taken::Continue
}

View File

@ -26,10 +26,14 @@ pub mod guest;
#[cfg(not(target_arch = "wasm32"))]
pub mod host;
#[cfg(not(target_arch = "wasm32"))]
pub mod hub;
#[cfg(not(target_arch = "wasm32"))]
mod link;
#[cfg(not(target_arch = "wasm32"))]
pub mod links;
#[cfg(not(target_arch = "wasm32"))]
pub mod manage;
#[cfg(not(target_arch = "wasm32"))]
pub mod net;
#[cfg(not(target_arch = "wasm32"))]
pub mod token;
@ -38,7 +42,7 @@ pub mod token;
mod web;
pub use inbox::{Inbox, Waker};
pub use invite::Invite;
pub use invite::{Endpoint, Invite};
pub use limits::NetLimits;
#[cfg(not(target_arch = "wasm32"))]
pub use link::{DEFAULT_PORT, NetLink, lan_ip};

View File

@ -2,13 +2,14 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use figcraft_collab::message::ClientMsg;
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, Role, SessionInfo};
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Role, SessionInfo};
use crate::guest::{Guest, GuestConfig};
use crate::guest::{Guest, GuestConfig, Publish};
use crate::host::{Host, HostConfig};
use crate::inbox::{Inbox, Waker};
use crate::invite::Invite;
use crate::invite::{Endpoint, Invite};
use crate::manage::NO_TLS;
use crate::net::NetLimits;
use crate::token;
@ -48,6 +49,13 @@ impl NetLink {
self
}
/// How this link joins `invite`.
fn guest_config(&self, invite: Invite, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
cfg.limits = self.limits;
cfg
}
/// The running host, if hosting.
pub fn host_handle(&self) -> Option<&Host> {
match &self.active {
@ -98,7 +106,7 @@ impl LiveLink for NetLink {
core: figcraft_collab::Limits::default(),
};
let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?;
let invite = Invite { host: host.addr().to_string(), file, token };
let invite = Invite::new(host.addr().to_string(), file, token);
let info = SessionInfo {
role: Role::Host,
me: host.me().client_id,
@ -110,6 +118,7 @@ impl LiveLink for NetLink {
url: invite.url(),
address: host.addr().to_string(),
network: !ip.is_loopback(),
access: Access::Edit,
};
self.inbox = Some(inbox);
self.active = Some(Active::Host(host));
@ -118,16 +127,36 @@ impl LiveLink for NetLink {
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
if invite.server.secure {
return Err(NO_TLS.into());
}
let cfg = self.guest_config(invite, name);
self.leave();
let inbox = Inbox::new(self.wake.clone());
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
cfg.limits = self.limits;
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
Ok(())
}
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
if server.secure {
return Err(NO_TLS.into());
}
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
self.leave();
let inbox = Inbox::new(self.wake.clone());
let job = Publish { server, key, name: o.name, doc: o.doc };
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
Ok(())
}
fn leave(&mut self) {
match self.active.take() {
Some(Active::Host(mut h)) => h.stop(),

View File

@ -93,7 +93,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
core,
};
let host = Host::start(cfg, probe.inbox.clone()).unwrap();
let invite = Invite { host: host.addr().to_string(), file: FILE.into(), token: TOKEN.into() };
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
(host, probe, invite)
}
@ -240,7 +240,7 @@ fn a_wrong_token_or_file_is_refused() {
fn unreachable_hosts_fail_at_once() {
// A port nothing listens on.
let free = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
let (_g, mut gp) = join(&Invite { host: free.to_string(), file: FILE.into(), token: TOKEN.into() }, "Ada");
let (_g, mut gp) = join(&Invite::new(free.to_string(), FILE, TOKEN), "Ada");
gp.wait("a failure", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.starts_with("Couldn't join"))));
assert!(!gp.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a first join doesn't retry");
}
@ -292,7 +292,7 @@ fn a_newcomer_sees_where_everyone_already_is() {
#[test]
fn presence_is_throttled_and_the_latest_wins() {
let (mut host, _hp, invite) = start();
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut watcher = Raw::hello(addr, TOKEN);
assert!(matches!(watcher.next(), Some(ServerMsg::Welcome { .. })));
let mut sender = Raw::hello(addr, TOKEN);
@ -336,7 +336,7 @@ fn leaving_and_dropping_clean_up() {
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
connections_settle(&host, 1);
// A connection that just vanishes (no close) counts as leaving too.
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id: rid, .. }) = raw.next() else { panic!("no welcome") };
drop(raw);
@ -366,7 +366,7 @@ fn a_full_session_refuses_more() {
#[test]
fn too_many_connections_are_turned_away() {
let (mut host, _hp, invite) = start_with(NetLimits { max_connections: 2, ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut a = Raw::hello(addr, TOKEN);
let mut b = Raw::hello(addr, TOKEN);
assert!(a.next().is_some() && b.next().is_some());
@ -380,7 +380,7 @@ fn too_many_connections_are_turned_away() {
#[test]
fn oversized_messages_end_the_connection() {
let (mut host, mut hp, invite) = start_with(NetLimits { max_client_message: 4096, ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id, .. }) = raw.next() else { panic!("no welcome") };
raw.send(&ClientMsg::Presence(Presence { selection: Some((0..5000).map(NodeId).collect()), ..Presence::default() }));
@ -395,7 +395,7 @@ fn oversized_messages_end_the_connection() {
#[test]
fn flooding_ends_the_connection() {
let (mut host, _hp, invite) = start_with(NetLimits { rate: 5.0, burst: 20.0, ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
assert!(matches!(raw.next(), Some(ServerMsg::Welcome { .. })));
for _ in 0..100 {
@ -410,7 +410,7 @@ fn flooding_ends_the_connection() {
#[test]
fn malformed_frames_are_refused() {
let (mut host, _hp, invite) = start();
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
// Garbage instead of a hello, a wrong wire version, text, and something other than a hello.
type Case = (Box<dyn Fn(&mut Raw)>, &'static str);
let cases: Vec<Case> = vec![
@ -445,7 +445,7 @@ fn random_frames_never_hurt_the_host() {
use proptest::strategy::{Strategy, ValueTree};
use proptest::test_runner::TestRunner;
let (mut host, _hp, invite) = start();
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut runner = TestRunner::deterministic();
let mut join_first = false;
let frames = proptest::collection::vec(proptest::collection::vec(proptest::num::u8::ANY, 0..200), 1..6);
@ -476,7 +476,7 @@ fn random_frames_never_hurt_the_host() {
#[test]
fn silent_connections_time_out() {
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_millis(200), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
let t0 = Instant::now();
@ -545,7 +545,7 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
let mut gp = probe;
gp.wait("welcome", welcomed);
// A joined raw client that never says anything again.
let addr: SocketAddr = invite.host.parse().unwrap();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id: silent, .. }) = raw.next() else { panic!("no welcome") };
hp.wait("the silent one is dropped", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == silent)));

View File

@ -0,0 +1,71 @@
//! Managing a server's files from a client: one connection that opens with the server's key
//! ([`Manage`]) and sends requests, each answered in order. The app publishes through it, and the
//! server's command line uses it to reach a running server.
use std::time::Duration;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::Endpoint;
use crate::net::{NetLimits, config, dial};
/// Why the desktop app can't reach a `wss://` address (it has no TLS of its own yet).
pub const NO_TLS: &str =
"The desktop app can't open secure (wss://) connections yet: use the server's ws:// address, or join from FigCraft in the browser";
/// Send `requests` to the server at `server` with its `key`; the replies, in order. Fails when the
/// server can't be reached, refuses the key or closes the connection (a request it can't do is a
/// [`AdminReply::Failed`] reply, not an error). Never echoes the key.
pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetLimits, timeout: Duration) -> Result<Vec<AdminReply>, String> {
if server.secure {
return Err(NO_TLS.into());
}
let stream = dial(&server.host, timeout, limits)?;
let (mut ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
// A large upload takes the server a while to keep.
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
send(&mut ws, &ClientMsg::Manage(manage))?;
match reply(&mut ws, limits)? {
AdminReply::Ready => {}
other => return Err(format!("the server answered {other:?} instead of letting this connection manage files")),
}
let mut replies = Vec::with_capacity(requests.len());
for r in requests {
send(&mut ws, &ClientMsg::Admin(r))?;
replies.push(reply(&mut ws, limits)?);
}
let _ = ws.close(None);
// Read until the server's close arrives (or the socket ends), so the close isn't lost.
while ws.read().is_ok() {}
Ok(replies)
}
fn send(ws: &mut WebSocket<std::net::TcpStream>, msg: &ClientMsg) -> Result<(), String> {
let bytes = message::encode(msg).map_err(|e| e.to_string())?;
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
}
/// The next answer: an admin reply, or why there is none.
fn reply(ws: &mut WebSocket<std::net::TcpStream>, limits: &NetLimits) -> Result<AdminReply, String> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
Ok(ServerMsg::Admin(r)) => Ok(*r),
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
Ok(ServerMsg::Pong) => continue,
Ok(_) => Err("the server sent something other than an answer".into()),
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
},
Ok(Message::Close(f)) => Err(match f.map(|f| f.reason.to_string()).filter(|r| !r.is_empty()) {
Some(r) => format!("the server closed the connection: {r}"),
None => "the server closed the connection".into(),
}),
Ok(Message::Text(_)) => Err("the server sent a text message".into()),
Ok(_) => continue,
Err(e) => Err(format!("no answer from the server ({e})")),
};
}
}

View File

@ -66,6 +66,26 @@ pub fn shutdown(s: &TcpStream) {
let _ = s.shutdown(Shutdown::Both);
}
/// Connect to `host` (`name:port`), trying each of its addresses within `timeout`, with Nagle off
/// and reads and writes bounded by `limits`' hello and write timeouts.
pub fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
use std::net::ToSocketAddrs;
let addrs = host.to_socket_addrs().map_err(|e| format!("can't find {host}: {e}"))?;
let mut last = format!("can't find {host}");
for a in addrs {
match TcpStream::connect_timeout(&a, timeout) {
Ok(s) => {
let _ = s.set_nodelay(true);
s.set_read_timeout(Some(limits.hello_timeout)).map_err(|e| e.to_string())?;
s.set_write_timeout(Some(limits.write_timeout)).map_err(|e| e.to_string())?;
return Ok(s);
}
Err(e) => last = format!("no answer from {a}: {e}"),
}
}
Err(last)
}
/// A token bucket: `rate` per second on average, `burst` at once.
#[derive(Debug)]
pub struct Bucket {

View File

@ -1,6 +1,7 @@
//! The browser client (step 4 of `docs/collab-protocol.md`): the web build joins a session a
//! desktop app hosts, through the browser's WebSocket API (`web-sys`), with the same messages as
//! the native guest. The connection logic is the [`Driver`]'s; this file feeds it the socket's
//! desktop app hosts, or a file on a `figcraft-server` (also over `wss://` behind a TLS reverse
//! proxy), through the browser's WebSocket API (`web-sys`), with the same messages as the native
//! guest. The connection logic is the [`Driver`]'s; this file feeds it the socket's
//! events and wakes it with a timer. Hosting needs the desktop app (a page can't listen on a
//! socket).
//!
@ -8,8 +9,8 @@
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only the session's
//! number and its [`Inbox`]. Whatever arrives wakes egui through the [`Waker`]; nothing polls.
//!
//! An `https://` page can't open the unencrypted `ws://` connection of a desktop-hosted session
//! (browsers block mixed content) except to this machine; [`NetLink::join`] says so up front.
//! An `https://` page can't open an unencrypted `ws://` connection (browsers block mixed content)
//! except to this machine; [`NetLink::join`] says so up front.
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
@ -167,8 +168,8 @@ impl LiveLink for NetLink {
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
if secure_page() && !invite.loopback() {
return Err("this page was loaded over https, and browsers block the unencrypted ws:// connection of a session the desktop app hosts; open FigCraft from an http:// address (like http://localhost:8767) or join from the desktop app".into());
if secure_page() && !invite.server.secure && !invite.loopback() {
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// link (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or join from the desktop app".into());
}
self.leave();
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));

View File

@ -166,6 +166,7 @@ node_props! {
Styles => styles,
Bound => bound,
VarModes => var_modes,
BoolOp => bool_op,
ConstrainProportions => constrain_proportions,
Source => source,
Exposed => exposed,

View File

@ -8,7 +8,7 @@ use figcraft_doc::{Document, NodeId};
use crate::change::{Change, Prop};
use crate::history::{BACK, BatchKind, FWD, Notice, Step, Touches};
use crate::message::{Accepted, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg};
use crate::replica::{Mode, Replica};
use crate::{Diff, Error, NoRecompute, Recompute, diff};
@ -59,6 +59,8 @@ pub struct Client {
next_step: u64,
history_limit: usize,
notices: Vec<Notice>,
/// What the server lets this client do (a viewer's edits are refused).
access: Access,
}
/// The step with id `id` in either stack.
@ -82,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, .. } = welcome else {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, access, .. } = welcome else {
return Err(Error::Protocol("expected a welcome with the document".into()));
};
let mut confirmed = Replica::from_snapshot(&snapshot)?;
@ -105,6 +107,7 @@ impl Client {
next_step: 1,
history_limit: HISTORY_LIMIT,
notices: Vec::new(),
access,
})
}
@ -113,6 +116,11 @@ impl Client {
self.id
}
/// What the server lets this client do (from the latest welcome).
pub fn access(&self) -> Access {
self.access
}
/// The document as this user sees it (server state plus their unconfirmed edits). Edit a copy
/// (it allocates ids in this client's space) and hand it to [`edit`](Self::edit).
pub fn document(&self) -> &Document {
@ -443,13 +451,15 @@ impl Client {
}
ServerMsg::Pong => Ok(Vec::new()),
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers } => {
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
if session != self.session || client_id != self.id {
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers });
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access });
}
self.access = access;
self.rejoin(seq, catch_up, &processed, peers, recompute)
}
ServerMsg::Refused { reason } => Err(Error::Protocol(reason)),
ServerMsg::Admin(_) => Err(Error::Protocol("an answer meant for a managing connection".into())),
}
}

View File

@ -4,8 +4,8 @@ use figcraft_doc::{Document, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::message::{
Accepted, CatchUp, ClientMsg, EditingText, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, WIRE_VERSION, decode, decode_json,
encode, encode_json,
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, Hello, LinkInfo, Manage, NewLink, PROTOCOL, Peer, Presence,
Processed, Resume, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
};
use crate::sim::Rng;
use crate::{Error, Replica, diff};
@ -49,8 +49,18 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
}),
ClientMsg::Ack { seq: 9 },
ClientMsg::Ping,
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: "k".into(), client_version: "0.3.1".into() }),
ClientMsg::Admin(Admin::Publish { name: "Café".into(), snapshot: Replica::new(Document::new("Up")).snapshot().unwrap() }),
ClientMsg::Admin(Admin::Files),
ClientMsg::Admin(Admin::Links { file: "f".into() }),
ClientMsg::Admin(Admin::CreateLink { file: "f".into(), access: Access::View, label: "Review".into() }),
ClientMsg::Admin(Admin::Revoke { file: "f".into(), link: "l".into() }),
ClientMsg::Admin(Admin::Remove { file: "f".into() }),
];
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 };
let link = LinkInfo { id: "l".into(), file: "f".into(), access: Access::Edit, label: String::new(), created: 6 };
let new_link = NewLink { link: link.clone(), token: "t".into() };
let server = vec![
ServerMsg::Welcome {
session: 77,
@ -59,6 +69,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
catch_up: CatchUp::Snapshot(snapshot),
processed: Processed::default(),
peers: vec![peer.clone()],
access: Access::Edit,
},
ServerMsg::Welcome {
session: 77,
@ -67,6 +78,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
catch_up: CatchUp::Delta(vec![Accepted { seq: 11, author: 5, batch_id: 1, changes: vec![set.clone()] }]),
processed: Processed { last_batch: 7, refused: vec![3, 6] },
peers: vec![],
access: Access::View,
},
ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }),
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
@ -75,6 +87,13 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
ServerMsg::PeerLeft { client_id: 4 },
ServerMsg::Refused { reason: "full".into() },
ServerMsg::Pong,
ServerMsg::Admin(Box::new(AdminReply::Ready)),
ServerMsg::Admin(Box::new(AdminReply::Published { file: file.clone(), edit: new_link.clone(), view: new_link.clone() })),
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file]))),
ServerMsg::Admin(Box::new(AdminReply::Links(vec![link]))),
ServerMsg::Admin(Box::new(AdminReply::Link(new_link))),
ServerMsg::Admin(Box::new(AdminReply::Done)),
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
];
(client, server)
}

View File

@ -0,0 +1,176 @@
//! View-only participants and the journal a store keeps between snapshots: a viewer's batches are
//! refused with the reason and rolled back, everything else reaches it; replaying the journal over
//! the last saved state gives the same server (clients resume into it), entries already in the
//! state change nothing, and a log with a gap is refused.
use figcraft_doc::{Document, NodeKind};
use crate::message::{self, Access, ClientMsg, Hello, ServerMsg};
use crate::sim_tests::base;
use crate::{Client, Journal, Limits, NoRecompute, Outgoing, Server, VIEW_ONLY};
fn hello() -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") };
h
}
fn join_as(s: &mut Server, access: Access) -> Client {
let (_, out) = s.join_as(hello(), access).unwrap();
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
let c = Client::join(welcome).unwrap();
assert_eq!(c.access(), access, "the welcome says what the client may do");
c
}
/// Deliver everything in `out` meant for `c`; returns what it sends back.
fn deliver(c: &mut Client, out: &[Outgoing]) -> Vec<ClientMsg> {
let id = c.id();
let mut replies = Vec::new();
for o in out {
let m = match o {
Outgoing::To(t, m) if *t == id => m,
Outgoing::All(m) => m,
Outgoing::Others(not, m) if *not != id => m,
_ => continue,
};
replies.extend(c.receive(m.clone(), &NoRecompute).unwrap());
}
replies
}
fn edit(c: &mut Client, f: impl FnOnce(&mut Document)) -> ClientMsg {
let mut d = c.document().clone();
f(&mut d);
c.edit(&d).unwrap().expect("a change")
}
/// `c` sends `m`; everyone in `all` gets what the server says.
fn send(s: &mut Server, from: u32, m: ClientMsg, all: &mut [&mut Client]) -> Vec<Outgoing> {
let out = s.receive(from, m);
for c in all.iter_mut() {
deliver(c, &out);
}
out
}
#[test]
fn a_viewer_sees_everything_and_changes_nothing() {
let mut s = Server::new(base(), "", Limits::default());
let mut editor = join_as(&mut s, Access::Edit);
let mut viewer = join_as(&mut s, Access::View);
let page = s.document().first_page().unwrap();
// The editor's edit reaches the viewer.
let m = edit(&mut editor, |d| {
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
});
send(&mut s, editor.id(), m, &mut [&mut editor, &mut viewer]);
assert_eq!(viewer.view(), s.replica());
// The viewer's edit is refused with the reason, and rolled back.
let seq = s.seq();
let m = edit(&mut viewer, |d| d.node_mut(page).unwrap().name = "Mine".into());
let out = send(&mut s, viewer.id(), m, &mut [&mut editor, &mut viewer]);
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == VIEW_ONLY));
assert_eq!(s.seq(), seq, "nothing accepted");
assert_eq!(viewer.view(), s.replica(), "the viewer's copy is the server's again");
assert!(viewer.take_notices().iter().any(|n| n.message().contains("view")), "the viewer is told why");
// Presence still goes both ways.
let out = s.receive(viewer.id(), ClientMsg::Presence(Default::default()));
assert!(matches!(out.as_slice(), [Outgoing::Others(_, ServerMsg::Presence { .. })]));
// Joining again with an edit link lifts it.
let id = viewer.id();
s.leave(id);
let mut again = join_as(&mut s, Access::Edit);
let m = edit(&mut again, |d| d.node_mut(page).unwrap().name = "Now mine".into());
let out = s.receive(again.id(), m);
assert!(matches!(out.as_slice(), [Outgoing::All(ServerMsg::Changes(_))]));
}
#[test]
fn managing_messages_are_refused_on_a_session_connection() {
let mut s = Server::new(base(), "", Limits::default());
let c = join_as(&mut s, Access::Edit);
let out = s.receive(c.id(), ClientMsg::Admin(message::Admin::Files));
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Refused { .. })]));
}
#[test]
fn the_journal_replayed_over_the_last_state_restores_the_server() {
let mut s = Server::new(base(), "", Limits::default());
s.journaling(true);
let mut a = join_as(&mut s, Access::Edit);
let mut v = join_as(&mut s, Access::View);
let page = s.document().first_page().unwrap();
// Saved here: what comes after is only in the journal.
let saved = s.state().unwrap();
s.take_journal();
let mut b = join_as(&mut s, Access::Edit);
for i in 0..5 {
let m = edit(&mut a, |d| {
d.create(page, NodeKind::Rectangle, &format!("A{i}"), f64::from(i), 0.0, 10.0, 10.0).unwrap();
});
send(&mut s, a.id(), m, &mut [&mut a, &mut b, &mut v]);
}
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Bo's page".into());
send(&mut s, b.id(), m, &mut [&mut a, &mut b, &mut v]);
let m = edit(&mut v, |d| d.node_mut(page).unwrap().name = "Refused".into());
send(&mut s, v.id(), m, &mut [&mut a, &mut b, &mut v]);
let journal = s.take_journal();
assert_eq!(journal.iter().filter(|j| matches!(j, Journal::Issued { .. })).count(), 1, "b's id");
assert_eq!(journal.iter().filter(|j| matches!(j, Journal::Accepted(_))).count(), 6);
assert_eq!(journal.iter().filter(|j| matches!(j, Journal::Refused { .. })).count(), 1);
assert!(s.take_journal().is_empty(), "taken");
// The entries survive both encodings (the store writes the binary one).
for j in &journal {
assert_eq!(&message::decode::<Journal>(&message::encode(j).unwrap(), usize::MAX).unwrap(), j);
}
// Restart: the saved state, then the journal.
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
for j in journal.iter().cloned() {
r.replay(j).unwrap();
}
assert_eq!(r.replica(), s.replica());
assert_eq!(r.seq(), s.seq());
let mut live = s.state().unwrap();
let mut back = r.state().unwrap();
// The session id is kept; the rest of the lasting state matches too.
assert_eq!(live.session, back.session);
live.snapshot.clear();
back.snapshot.clear();
assert_eq!(live, back);
// Replaying again changes nothing (a log that outlived a compaction).
for j in journal {
r.replay(j).unwrap();
}
assert_eq!(r.replica(), s.replica());
assert!(r.take_journal().is_empty(), "replays aren't journaled");
// Every client resumes into the restarted server with nothing to send again, and a newcomer
// gets an id nobody had.
for c in [&mut a, &mut b] {
c.disconnect();
let ClientMsg::Hello(h) = c.reconnect(hello()) else { panic!() };
let (id, out) = r.join(h).unwrap();
assert_eq!(id, c.id());
assert!(deliver(c, &out).is_empty());
assert_eq!(c.view(), r.replica());
}
let n = join_as(&mut r, Access::Edit);
assert!(n.id() > b.id());
}
#[test]
fn a_journal_with_a_gap_is_refused() {
let mut s = Server::new(base(), "", Limits::default());
s.journaling(true);
let saved = s.state().unwrap();
let mut a = join_as(&mut s, Access::Edit);
let page = s.document().first_page().unwrap();
for name in ["One", "Two"] {
let m = edit(&mut a, |d| d.node_mut(page).unwrap().name = name.into());
send(&mut s, a.id(), m, &mut [&mut a]);
}
let journal: Vec<Journal> = s.take_journal().into_iter().filter(|j| !matches!(j, Journal::Accepted(acc) if acc.seq == 1)).collect();
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
let errors: Vec<_> = journal.into_iter().filter_map(|j| r.replay(j).err()).collect();
assert_eq!(errors.len(), 1, "the batch after the gap");
assert_eq!(r.seq(), 0);
}

View File

@ -38,9 +38,15 @@ pub use client::{Client, HISTORY_LIMIT};
pub use diff::{Diff, diff};
pub use figcraft_doc::{Document, NodeId};
pub use history::{BatchKind, Notice};
pub use message::{Accepted, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg};
pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg};
pub use replica::{Applied, Mode, Replica};
pub use server::{Outgoing, Server, SessionState};
pub use server::{Journal, Outgoing, Server, SessionState, VIEW_ONLY};
/// Compare secrets (tokens, keys, their hashes) without stopping at the first difference.
pub fn same_secret(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// What can go wrong syncing.
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
@ -119,6 +125,8 @@ mod entry_ids_tests;
#[cfg(test)]
mod frac_tests;
#[cfg(test)]
mod journal_tests;
#[cfg(test)]
mod reconnect_tests;
#[cfg(test)]
mod server_tests;

View File

@ -8,8 +8,9 @@ use serde::{Deserialize, Serialize};
use crate::Error;
use crate::change::Change;
/// The protocol version a client announces in [`Hello`].
pub const PROTOCOL: u32 = 1;
/// The protocol version a client announces in [`Hello`] (2: the welcome says what you may do, and
/// connections can manage a server).
pub const PROTOCOL: u32 = 2;
/// The first byte of every binary message.
pub const WIRE_VERSION: u8 = 1;
@ -32,6 +33,35 @@ pub struct Hello {
pub resume: Option<Resume>,
}
/// What a participant may do: edit, or only view (a view link). A viewer gets everyone's changes
/// and presence, and the server refuses every batch it sends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Access {
#[default]
Edit,
View,
}
impl Access {
/// `"edit"` or `"view"`.
pub fn id(self) -> &'static str {
match self {
Access::Edit => "edit",
Access::View => "view",
}
}
/// From [`id`](Self::id) (any case, surrounding spaces ignored).
pub fn parse(s: &str) -> Option<Access> {
match s.trim().to_ascii_lowercase().as_str() {
"edit" => Some(Access::Edit),
"view" => Some(Access::View),
_ => None,
}
}
}
/// What a reconnecting client tells the server in its [`Hello`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Resume {
@ -115,6 +145,93 @@ pub enum CatchUp {
Delta(Vec<Accepted>),
}
/// Opens a connection that manages a server's files (instead of [`Hello`]): the server's admin
/// key. Answered with [`AdminReply::Ready`] or `Refused`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Manage {
pub protocol: u32,
pub key: String,
pub client_version: String,
}
/// A request on a managing connection; each gets one [`AdminReply`], in order.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Admin {
/// Keep a document as a new file: its name and its snapshot
/// ([`Replica::snapshot`](crate::Replica::snapshot)). The reply has a new edit link and a new
/// view link for it.
Publish { name: String, snapshot: Vec<Change> },
/// The files kept.
Files,
/// A file's links (without their tokens).
Links { file: String },
/// A new link to a file.
CreateLink { file: String, access: Access, label: String },
/// Revoke a link: it no longer joins, and whoever joined with it is disconnected.
Revoke { file: String, link: String },
/// Remove a file and its links (whoever is in it is disconnected).
Remove { file: String },
}
/// A file a server keeps.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FileInfo {
/// Its id (in links: `file=<id>`).
pub id: String,
pub name: String,
/// Seconds since 1970 (UTC).
pub created: u64,
pub updated: u64,
/// The sequence number of its last accepted batch.
pub seq: u64,
/// Bytes on disk.
pub bytes: u64,
/// People in it now.
pub people: u32,
}
/// A share link (the server keeps only a hash of its token).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LinkInfo {
/// Its id (to revoke it by; not a secret).
pub id: String,
pub file: String,
pub access: Access,
pub label: String,
/// Seconds since 1970 (UTC).
pub created: u64,
}
/// A link just made, with its token: shown this once.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct NewLink {
pub link: LinkInfo,
pub token: String,
}
/// The answer to an [`Admin`] request (the first one on a connection answers [`Manage`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AdminReply {
/// The key is right: requests may follow.
Ready,
Published {
file: FileInfo,
edit: NewLink,
view: NewLink,
},
Files(Vec<FileInfo>),
Links(Vec<LinkInfo>),
Link(NewLink),
/// Done (a revoke or a removal).
Done,
/// The request couldn't be done (the connection stays open).
Failed {
reason: String,
},
}
/// Client → server.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -133,6 +250,10 @@ pub enum ClientMsg {
seq: u64,
},
Ping,
/// Manage the server's files (the first message on a connection, instead of `Hello`).
Manage(Manage),
/// A request on a managing connection.
Admin(Admin),
}
/// Server → client.
@ -142,7 +263,7 @@ pub enum ServerMsg {
/// Joined: the session's identity (a server that restarted without its saved state is a new
/// session), this connection's client id, the current sequence number, the document (or the
/// batches missed since a reconnecting client's last sequence number), what the server did
/// with this client's batches so far and who else is here.
/// with this client's batches so far, who else is here and what this client may do.
Welcome {
session: u64,
client_id: u32,
@ -150,6 +271,7 @@ pub enum ServerMsg {
catch_up: CatchUp,
processed: Processed,
peers: Vec<Peer>,
access: Access,
},
/// Accepted changes, in order, to everyone (the author's own come back as confirmation).
Changes(Accepted),
@ -175,6 +297,8 @@ pub enum ServerMsg {
reason: String,
},
Pong,
/// The answer to a managing connection's request.
Admin(Box<AdminReply>),
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.

View File

@ -7,9 +7,9 @@ use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::message::{Accepted, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg};
use crate::replica::{Mode, Replica, create_change};
use crate::{Error, Limits, docprops};
use crate::{Error, Limits, docprops, same_secret};
/// A message for the transport to deliver.
#[derive(Clone, Debug, PartialEq)]
@ -22,6 +22,23 @@ pub enum Outgoing {
Others(u32, ServerMsg),
}
/// Why a viewer's batch is refused.
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
/// What changed a server's lasting state, in order: what a store appends to its log between
/// snapshots ([`Server::journaling`]) and replays over the last snapshot after a restart
/// ([`Server::replay`]). Presence and who is connected aren't in it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Journal {
/// A client id was handed out.
Issued { client: u32 },
/// A batch was accepted.
Accepted(Accepted),
/// A client's batch was refused.
Refused { client: u32, batch_id: u64 },
}
/// A session server for one file.
#[derive(Clone, Debug)]
pub struct Server {
@ -48,6 +65,10 @@ pub struct Server {
log: VecDeque<Accepted>,
/// Changes in `log`.
log_changes: usize,
/// The connected clients that may only view.
viewers: BTreeSet<u32>,
/// What changed the lasting state since it was last taken (`None`: not kept).
journal: Option<Vec<Journal>>,
}
/// The most client ids a session remembers for reconnecting (the oldest disconnected ones go
@ -92,6 +113,8 @@ impl Server {
issued: BTreeMap::new(),
log: VecDeque::new(),
log_changes: 0,
viewers: BTreeSet::new(),
journal: None,
}
}
@ -131,6 +154,66 @@ impl Server {
Ok(server)
}
/// Keep a [`Journal`] of what changes the lasting state (`on`), or stop (dropping what wasn't
/// taken).
pub fn journaling(&mut self, on: bool) {
self.journal = on.then(|| self.journal.take().unwrap_or_default());
}
/// What changed the lasting state since the last call, oldest first.
pub fn take_journal(&mut self) -> Vec<Journal> {
self.journal.as_mut().map(std::mem::take).unwrap_or_default()
}
fn note(&mut self, j: Journal) {
if let Some(v) = self.journal.as_mut() {
v.push(j);
}
}
/// Apply a journal entry over a restored state (after a restart). What the state already has
/// (a batch at or below its sequence number, a client id it knows, a refusal it noted)
/// changes nothing; a batch that doesn't follow the sequence number, or doesn't apply, is an
/// error (the rest of that log can't be trusted). Replayed entries aren't journaled again.
pub fn replay(&mut self, j: Journal) -> Result<(), Error> {
match j {
Journal::Issued { client } => {
if let std::collections::btree_map::Entry::Vacant(v) = self.issued.entry(client) {
v.insert(Processed::default());
self.next_client = self.next_client.max(client.saturating_add(1));
self.forget_oldest();
}
Ok(())
}
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
Journal::Accepted(acc) => {
if acc.seq != self.seq + 1 {
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
}
self.accept(acc.author, self.seq, &acc.changes)?;
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
self.next_client = self.next_client.max(acc.author.saturating_add(1));
self.remember(acc);
Ok(())
}
Journal::Refused { client, batch_id } => {
if let Some(p) = self.issued.get_mut(&client).filter(|p| batch_id > p.last_batch) {
p.note(batch_id, true);
}
Ok(())
}
}
}
/// Beyond [`MAX_REMEMBERED`] client ids, forget the oldest one that isn't connected.
fn forget_oldest(&mut self) {
if self.issued.len() > MAX_REMEMBERED
&& let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k))
{
self.issued.remove(&old);
}
}
/// The authoritative document.
pub fn document(&self) -> &Document {
self.state.document()
@ -163,6 +246,11 @@ impl Server {
/// snapshot when they are no longer in the log; an id this session never gave out joins as
/// new.
pub fn join(&mut self, hello: Hello) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
self.join_as(hello, Access::Edit)
}
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused.
pub fn join_as(&mut self, hello: Hello, access: Access) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
let refuse = |reason: &str| ServerMsg::Refused { reason: reason.to_string() };
if hello.protocol != PROTOCOL {
return Err(refuse(&format!("protocol {} isn't supported (this server speaks {PROTOCOL})", hello.protocol)));
@ -192,11 +280,8 @@ impl Server {
let id = self.next_client;
self.next_client += 1;
self.issued.insert(id, Processed::default());
if self.issued.len() > MAX_REMEMBERED
&& let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k))
{
self.issued.remove(&old);
}
self.forget_oldest();
self.note(Journal::Issued { client: id });
id
}
};
@ -204,8 +289,13 @@ impl Server {
self.presence.remove(&id);
let peer = Peer { client_id: id, name: hello.name, color: hello.color };
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others };
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access };
self.peers.insert(id, peer.clone());
if access == Access::View {
self.viewers.insert(id);
} else {
self.viewers.remove(&id);
}
Ok((id, vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))]))
}
@ -213,6 +303,7 @@ impl Server {
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
self.presence.remove(&id);
self.acked.remove(&id);
self.viewers.remove(&id);
match self.peers.remove(&id) {
Some(_) => vec![Outgoing::Others(id, ServerMsg::PeerLeft { client_id: id })],
None => Vec::new(),
@ -230,7 +321,7 @@ impl Server {
// client learned what happened to it from the catch-up.
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
ClientMsg::Changes { base_seq, batch_id, changes } => {
let result = self.accept(from, base_seq, &changes);
let result = if self.viewers.contains(&from) { Err(Error::Invalid(VIEW_ONLY.into())) } else { self.accept(from, base_seq, &changes) };
if let Some(p) = self.issued.get_mut(&from) {
p.note(batch_id, result.is_err());
}
@ -238,9 +329,11 @@ impl Server {
Ok(()) => {
let acc = Accepted { seq: self.seq, author: from, batch_id, changes };
self.remember(acc.clone());
self.note(Journal::Accepted(acc.clone()));
vec![Outgoing::All(ServerMsg::Changes(acc))]
}
Err(e) => {
self.note(Journal::Refused { client: from, batch_id });
let fixes = self.fixes(&changes).unwrap_or_default();
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: reason(&e), fixes })]
}
@ -255,6 +348,9 @@ impl Server {
Vec::new()
}
ClientMsg::Ping => vec![Outgoing::To(from, ServerMsg::Pong)],
ClientMsg::Manage(_) | ClientMsg::Admin(_) => {
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files needs a connection of its own".into() })]
}
}
}
@ -421,9 +517,3 @@ fn reason(e: &Error) -> String {
e => e.to_string(),
}
}
/// Compare secrets without stopping at the first difference.
fn same_secret(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}

View File

@ -0,0 +1,64 @@
//! Clicking into boolean groups: a hit reaches the operand under the pointer, a plain click still
//! picks the group, and a double click goes one level deeper than the selection.
use crate::geometry::{enter_target, select_target};
use crate::{BoolOp, Document, NodeId, NodeKind};
use figcraft_geom::Point;
fn page(d: &Document) -> NodeId {
d.first_page().unwrap()
}
/// A frame holding a group holding a boolean group of two overlapping squares.
fn setup() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 300.0).unwrap();
let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
let b = d.create(g, NodeKind::BooleanOp { op: BoolOp::Subtract }, "Subtract", 0.0, 0.0, 0.0, 0.0).unwrap();
let r1 = d.create(b, NodeKind::Rectangle, "R1", 10.0, 10.0, 100.0, 100.0).unwrap();
let r2 = d.create(b, NodeKind::Rectangle, "R2", 60.0, 60.0, 100.0, 100.0).unwrap();
d.fit_to_children(b).unwrap();
d.fit_to_children(g).unwrap();
(d, f, g, b, r1, r2)
}
#[test]
fn hits_reach_operands_but_clicks_pick_the_group() {
let (d, f, g, b, r1, r2) = setup();
let p = page(&d);
assert_eq!(d.hit_test(p, Point::new(20.0, 20.0), 0.0), Some(r1));
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0), Some(r2), "the top operand where they overlap");
// A plain click: the group in the frame; Cmd-click: the operand.
assert_eq!(select_target(&d, r1, &[], false), g);
assert_eq!(select_target(&d, r1, &[], true), r1);
// With the boolean group selected, its siblings resolve as usual; with an operand selected, a
// click on another operand picks that one.
assert_eq!(select_target(&d, r2, &[r1], false), r2);
// "Select layer" still lists the boolean group as one layer.
let listed: Vec<NodeId> = crate::geometry::layers_at(&d, p, Point::new(20.0, 20.0), 0.0).into_iter().map(|(id, _)| id).collect();
assert_eq!(listed, vec![f, g, b]);
}
#[test]
fn double_clicks_go_one_level_deeper() {
let (d, f, g, b, r1, _) = setup();
assert_eq!(enter_target(&d, r1, &[]), g, "nothing selected: the plain click target");
assert_eq!(enter_target(&d, r1, &[g]), b, "into the group");
assert_eq!(enter_target(&d, r1, &[b]), r1, "into the boolean group");
assert_eq!(enter_target(&d, r1, &[r1]), r1, "the leaf itself: nothing deeper, it stays");
assert_eq!(enter_target(&d, r1, &[f]), g);
// The deepest selected layer that holds it counts.
assert_eq!(enter_target(&d, r1, &[f, b]), r1);
}
#[test]
fn a_top_level_group_is_entered_too() {
let mut d = Document::new("x");
let p = page(&d);
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
let r = d.create(g, NodeKind::Rectangle, "R", 10.0, 10.0, 20.0, 20.0).unwrap();
d.fit_to_children(g).unwrap();
assert_eq!(select_target(&d, r, &[g], false), g, "a click keeps the group");
assert_eq!(enter_target(&d, r, &[g]), r, "a double click enters it");
}

View File

@ -117,15 +117,9 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Opt
if clips && !hits_node_itself(doc, id, n, pt, 0.0) {
return None;
}
if matches!(n.kind, NodeKind::BooleanOp { .. }) {
// A boolean group is hit as one shape (approximated by any child hit).
for c in n.children.iter().rev() {
if hit_rec(doc, *c, pt, tol, depth + 1).is_some() {
return Some(id);
}
}
return None;
}
// A boolean group is hit where an operand is (an approximation of its result). The leaf is the
// operand, so a plain click still selects the group (see `select_target`) while a deep click,
// a double click or a selected sibling operand reaches the operand itself.
for c in n.children.iter().rev() {
if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1) {
return Some(h);
@ -222,6 +216,17 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo
top
}
/// What a double click on `leaf` selects: one level deeper than the selection, i.e. the layer
/// holding `leaf` just inside the deepest selected layer that holds it (entering groups, frames
/// and boolean groups), or the plain click target when no selected layer holds it.
pub fn enter_target(doc: &Document, leaf: NodeId, selection: &[NodeId]) -> NodeId {
let mut chain: Vec<NodeId> = doc.ancestors(leaf);
chain.reverse();
chain.push(leaf);
let inner = chain.iter().rposition(|c| selection.contains(c)).and_then(|i| chain.get(i + 1)).copied();
inner.unwrap_or_else(|| select_target(doc, leaf, selection, false))
}
/// Nodes on `page` whose world bounds intersect `rect` (marquee): top-level children, or the
/// children of top-level frames when the marquee starts inside a frame (`within`).
pub fn marquee(doc: &Document, page: NodeId, rect: Rect, within: Option<NodeId>) -> Vec<NodeId> {

View File

@ -993,6 +993,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
#[cfg(test)]
mod blur_tests;
#[cfg(test)]
mod boolean_hit_tests;
#[cfg(test)]
mod client_id_tests;
#[cfg(test)]
mod colors_tests;
@ -1015,4 +1017,6 @@ mod proto_expr_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod width_sides_tests;
#[cfg(test)]
mod width_tests;

View File

@ -443,6 +443,10 @@ pub struct Node {
/// Explicit variable modes (collection → mode) set on this container.
#[serde(default, skip_serializing_if = "is_default")]
pub var_modes: BTreeMap<CollectionId, ModeId>,
/// In a boolean group: how this operand combines with the operands below it (`None`: the
/// group's operation). Ignored for the bottom operand and outside boolean groups.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bool_op: Option<BoolOp>,
/// Lock aspect ratio when resizing from the panel.
#[serde(default, skip_serializing_if = "is_default")]
pub constrain_proportions: bool,
@ -574,6 +578,7 @@ impl Node {
styles: StyleRefs::default(),
bound: BTreeMap::new(),
var_modes: BTreeMap::new(),
bool_op: None,
constrain_proportions: false,
source: None,
exposed: false,

View File

@ -112,17 +112,74 @@ impl StrokeProps {
}
/// One point of a variable width profile.
///
/// The width can differ on the two sides of the path. Looking along the path on screen (y down),
/// the left side reaches `weight × scale × (1 − bias) / 2` from the centre line and the right side
/// `weight × scale × (1 + bias) / 2`. A side's **side scale** (`scale × (1 ∓ bias)`) is in the units
/// of `scale`: a symmetric point has both side scales equal to `scale`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct WidthPoint {
/// Position along the path by length: 0 at its start, 1 at its end (each subpath on its own).
pub t: f64,
/// The stroke's width here as a multiple of the stroke weight.
/// The stroke's width here as a multiple of the stroke weight (the mean of the side scales).
pub scale: f64,
/// How much wider the right side is than the left, -1..=1 (0: symmetric; 1: all on the right).
#[serde(default, skip_serializing_if = "is_zero")]
pub bias: f64,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
/// A side of a path for one-sided widths (looking along the path on screen, y down).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WidthSide {
Left,
Right,
}
impl WidthSide {
pub fn from_id(s: &str) -> Option<WidthSide> {
match s.to_ascii_lowercase().as_str() {
"left" => Some(WidthSide::Left),
"right" => Some(WidthSide::Right),
_ => None,
}
}
pub fn id(self) -> &'static str {
match self {
WidthSide::Left => "left",
WidthSide::Right => "right",
}
}
}
impl WidthPoint {
/// A symmetric point.
pub const fn new(t: f64, scale: f64) -> Self {
Self { t, scale }
Self { t, scale, bias: 0.0 }
}
/// A point from its left and right side scales (negative or non-finite sides count as 0).
pub fn from_sides(t: f64, left: f64, right: f64) -> Self {
let clean = |v: f64| if v.is_finite() { v.max(0.0) } else { 0.0 };
let (l, r) = (clean(left), clean(right));
let sum = l + r;
let bias = if sum > 1e-12 { ((r - l) / sum).clamp(-1.0, 1.0) } else { 0.0 };
Self { t, scale: sum / 2.0, bias }
}
/// The left and right side scales.
pub fn sides(&self) -> (f64, f64) {
(self.scale * (1.0 - self.bias), self.scale * (1.0 + self.bias))
}
/// Whether both sides are as wide.
pub fn symmetric(&self) -> bool {
self.bias == 0.0
}
}
@ -160,75 +217,127 @@ impl WidthProfile {
.iter()
.filter(|p| p.t.is_finite() && p.scale.is_finite())
.take(Self::MAX_POINTS)
.map(|p| WidthPoint::new(p.t.clamp(0.0, 1.0), p.scale.clamp(0.0, Self::MAX_SCALE)))
.map(|p| WidthPoint {
t: p.t.clamp(0.0, 1.0),
scale: p.scale.clamp(0.0, Self::MAX_SCALE),
bias: if p.bias.is_finite() { p.bias.clamp(-1.0, 1.0) } else { 0.0 },
})
.collect();
points.sort_by(|a, b| a.t.total_cmp(&b.t));
Self { points }
}
/// The width scale at `t` (0..1 along the path).
/// The width scale at `t` (0..1 along the path): the mean of the two sides' scales.
pub fn scale_at(&self, t: f64) -> f64 {
let (l, r) = self.sides_at(t);
(l + r) / 2.0
}
/// The left and right side scales at `t`, each side interpolated on its own.
pub fn sides_at(&self, t: f64) -> (f64, f64) {
if self.symmetric() {
let s = self.interp(t, |p| p.scale);
return (s, s);
}
(self.interp(t, |p| p.sides().0), self.interp(t, |p| p.sides().1))
}
/// Whether every point is as wide on both sides.
pub fn symmetric(&self) -> bool {
self.points.iter().all(WidthPoint::symmetric)
}
/// `value` of the points at `t`: monotone cubic between points, held flat before the first and
/// after the last.
fn interp(&self, t: f64, value: impl Fn(&WidthPoint) -> f64) -> f64 {
let pts = &self.points;
let (Some(first), Some(last)) = (pts.first(), pts.last()) else { return 1.0 };
if !t.is_finite() || t <= first.t {
return first.scale;
return value(first);
}
if t >= last.t {
return last.scale;
return value(last);
}
let k = pts.partition_point(|p| p.t <= t).saturating_sub(1);
let (Some(a), Some(b)) = (pts.get(k), pts.get(k + 1)) else { return last.scale };
let (Some(a), Some(b)) = (pts.get(k), pts.get(k + 1)) else { return value(last) };
let h = b.t - a.t;
if h <= 1e-12 {
return b.scale;
return value(b);
}
let u = (t - a.t) / h;
let (u2, u3) = (u * u, u * u * u);
let w = (2.0 * u3 - 3.0 * u2 + 1.0) * a.scale
+ (u3 - 2.0 * u2 + u) * h * self.tangent(k)
+ (3.0 * u2 - 2.0 * u3) * b.scale
+ (u3 - u2) * h * self.tangent(k + 1);
let w = (2.0 * u3 - 3.0 * u2 + 1.0) * value(a)
+ (u3 - 2.0 * u2 + u) * h * self.tangent(k, &value)
+ (3.0 * u2 - 2.0 * u3) * value(b)
+ (u3 - u2) * h * self.tangent(k + 1, &value);
w.max(0.0)
}
/// Slope of the segment from point `i` to `i + 1` (0 when they share a position).
fn secant(&self, i: usize) -> f64 {
/// Slope of `value` from point `i` to `i + 1` (0 when they share a position).
fn secant(&self, i: usize, value: &impl Fn(&WidthPoint) -> f64) -> f64 {
match (self.points.get(i), self.points.get(i + 1)) {
(Some(a), Some(b)) if b.t - a.t > 1e-12 => (b.scale - a.scale) / (b.t - a.t),
(Some(a), Some(b)) if b.t - a.t > 1e-12 => (value(b) - value(a)) / (b.t - a.t),
_ => 0.0,
}
}
/// Fritsch–Carlson style tangent at point `i`: one-sided at the ends, the harmonic mean of the
/// neighbouring slopes inside, flat at local extremes (keeps the curve monotone between points).
fn tangent(&self, i: usize) -> f64 {
fn tangent(&self, i: usize, value: &impl Fn(&WidthPoint) -> f64) -> f64 {
let last = self.points.len().saturating_sub(1);
if i == 0 {
return self.secant(0);
return self.secant(0, value);
}
if i >= last {
return self.secant(last.saturating_sub(1));
return self.secant(last.saturating_sub(1), value);
}
let (d0, d1) = (self.secant(i - 1), self.secant(i));
let (d0, d1) = (self.secant(i - 1, value), self.secant(i, value));
if d0 * d1 <= 0.0 { 0.0 } else { 2.0 * d0 * d1 / (d0 + d1) }
}
/// The largest scale (the interpolation never exceeds its points).
/// The largest side scale (the interpolation never exceeds its points): the widest the
/// stroke reaches from its centre line is `weight × max_scale / 2`.
pub fn max_scale(&self) -> f64 {
self.points.iter().map(|p| p.scale).fold(0.0, f64::max)
self.points.iter().map(|p| p.sides().0.max(p.sides().1)).fold(0.0, f64::max)
}
/// Add a point (kept sorted); returns its index.
/// Add a symmetric point (kept sorted); returns its index.
pub fn insert(&mut self, t: f64, scale: f64) -> usize {
let p = WidthPoint::new(
if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 },
if scale.is_finite() { scale.clamp(0.0, Self::MAX_SCALE) } else { 1.0 },
);
self.insert_point(WidthPoint::new(t, if scale.is_finite() { scale } else { 1.0 }))
}
/// Add a point at `t` as wide on each side as the stroke already is there; returns its index.
pub fn insert_here(&mut self, t: f64) -> usize {
let (l, r) = self.sides_at(t);
self.insert_point(WidthPoint::from_sides(t, l, r))
}
fn insert_point(&mut self, mut p: WidthPoint) -> usize {
p.t = if p.t.is_finite() { p.t.clamp(0.0, 1.0) } else { 0.0 };
p.scale = p.scale.clamp(0.0, Self::MAX_SCALE);
let i = self.points.partition_point(|q| q.t <= p.t);
self.points.insert(i, p);
i
}
/// Set one side of point `i` to side scale `scale`, keeping the other side. Returns false when
/// there is no such point.
pub fn set_side(&mut self, i: usize, side: WidthSide, scale: f64) -> bool {
let Some(p) = self.points.get_mut(i) else { return false };
if !scale.is_finite() {
return true;
}
let s = scale.clamp(0.0, 2.0 * Self::MAX_SCALE);
let (l, r) = p.sides();
let q = match side {
WidthSide::Left => WidthPoint::from_sides(p.t, s, r),
WidthSide::Right => WidthPoint::from_sides(p.t, l, s),
};
p.scale = q.scale.min(Self::MAX_SCALE);
p.bias = q.bias;
true
}
/// Move point `i` along the path (kept between its neighbours, so indices stay put) and/or
/// change its scale. Returns false when there is no such point.
pub fn set_point(&mut self, i: usize, t: Option<f64>, scale: Option<f64>) -> bool {
@ -238,8 +347,10 @@ impl WidthProfile {
if let Some(t) = t.filter(|t| t.is_finite()) {
p.t = t.clamp(lo, hi.max(lo));
}
// A new width makes the point symmetric again.
if let Some(s) = scale.filter(|s| s.is_finite()) {
p.scale = s.clamp(0.0, Self::MAX_SCALE);
p.bias = 0.0;
}
true
}

View File

@ -0,0 +1,106 @@
//! One-sided variable widths: width points whose sides differ, interpolated side by side, edited
//! one side at a time, sanitized, and saved without changing files that don't use them.
use crate::{StrokeProps, WidthPoint, WidthPreset, WidthProfile, WidthSide};
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn a_point_is_built_from_and_reports_its_sides() {
let p = WidthPoint::from_sides(0.5, 1.0, 3.0);
assert!(near(p.scale, 2.0) && near(p.bias, 0.5), "{p:?}");
let (l, r) = p.sides();
assert!(near(l, 1.0) && near(r, 3.0));
assert!(!p.symmetric());
// All on one side, nothing at all, and junk.
assert_eq!(WidthPoint::from_sides(0.0, 0.0, 2.0).sides(), (0.0, 2.0));
assert_eq!(WidthPoint::from_sides(0.0, 0.0, 0.0), WidthPoint::new(0.0, 0.0));
assert_eq!(WidthPoint::from_sides(0.0, f64::NAN, -4.0), WidthPoint::new(0.0, 0.0));
assert!(WidthPoint::from_sides(0.0, 2.0, 2.0).symmetric());
assert_eq!(WidthSide::from_id("LEFT"), Some(WidthSide::Left));
assert_eq!(WidthSide::from_id("up"), None);
assert_eq!(WidthSide::Right.id(), "right");
}
#[test]
fn sides_interpolate_on_their_own_and_symmetric_profiles_are_unchanged() {
// Left stays 1 while the right swells to 3 and back.
let p = WidthProfile { points: vec![WidthPoint::new(0.0, 1.0), WidthPoint::from_sides(0.5, 1.0, 3.0), WidthPoint::new(1.0, 1.0)] }.sanitized();
assert!(!p.symmetric());
for i in 0..=20 {
let t = f64::from(i) / 20.0;
let (l, r) = p.sides_at(t);
assert!(near(l, 1.0), "the left side holds at {t}: {l}");
assert!((1.0..=3.0 + 1e-9).contains(&r), "{t}: {r}");
assert!(near(p.scale_at(t), (l + r) / 2.0));
}
assert!(near(p.sides_at(0.5).1, 3.0));
// The widest side bounds the stroke.
assert!(near(p.max_scale(), 3.0));
// A symmetric profile interpolates exactly as before.
let eye = WidthPreset::Eye.profile().unwrap();
assert!(eye.symmetric());
for i in 0..=10 {
let t = f64::from(i) / 10.0;
let (l, r) = eye.sides_at(t);
assert_eq!(l, r);
assert_eq!(eye.scale_at(t), l);
}
}
#[test]
fn one_side_is_edited_and_a_new_width_makes_a_point_symmetric_again() {
let mut p = WidthProfile::uniform();
assert!(p.set_side(1, WidthSide::Right, 4.0));
assert_eq!(p.points[1].sides(), (1.0, 4.0));
assert!(p.set_side(1, WidthSide::Left, 0.0));
assert_eq!(p.points[1].sides(), (0.0, 4.0));
assert!(!p.set_side(9, WidthSide::Left, 1.0), "no such point");
assert!(p.set_side(1, WidthSide::Left, f64::NAN), "junk leaves it as it was");
assert_eq!(p.points[1].sides(), (0.0, 4.0));
// A point added between keeps both sides as wide as the stroke is there.
let i = p.insert_here(0.5);
let (l, r) = p.points[i].sides();
let (wl, wr) = p.sides_at(0.5);
assert!(near(l, wl) && near(r, wr), "{l} {r} vs {wl} {wr}");
assert!(l < r);
// Setting the width (both sides) makes it symmetric.
assert!(p.set_point(i, None, Some(2.0)));
assert!(p.points[i].symmetric());
assert_eq!(p.points[i].scale, 2.0);
}
#[test]
fn hostile_biases_are_sanitized() {
let p = WidthProfile {
points: vec![
WidthPoint { t: 0.2, scale: 1.0, bias: f64::NAN },
WidthPoint { t: 0.8, scale: 1.0, bias: 7.0 },
WidthPoint { t: 0.5, scale: 2.0, bias: -1e9 },
],
}
.sanitized();
assert_eq!(p.points.iter().map(|q| q.bias).collect::<Vec<_>>(), vec![0.0, -1.0, 1.0]);
for i in 0..=10 {
let (l, r) = p.sides_at(f64::from(i) / 10.0);
assert!(l.is_finite() && r.is_finite() && l >= 0.0 && r >= 0.0);
}
}
#[test]
fn files_keep_their_shape() {
// Symmetric points save exactly as before (no bias key); one-sided ones round-trip.
let sym = serde_json::to_value(WidthPoint::new(0.5, 2.0)).unwrap();
assert_eq!(sym, serde_json::json!({"t": 0.5, "scale": 2.0}));
let old: WidthPoint = serde_json::from_value(serde_json::json!({"t": 0.25, "scale": 3.0})).unwrap();
assert_eq!(old, WidthPoint::new(0.25, 3.0));
let one = WidthPoint::from_sides(0.5, 1.0, 3.0);
let back: WidthPoint = serde_json::from_value(serde_json::to_value(one).unwrap()).unwrap();
assert_eq!(back, one);
let stroke = StrokeProps { width_profile: Some(WidthProfile { points: vec![one] }), ..Default::default() };
let back: StrokeProps = serde_json::from_str(&serde_json::to_string(&stroke).unwrap()).unwrap();
assert_eq!(back, stroke);
assert!(near(back.max_weight(), 3.0));
}

View File

@ -0,0 +1,68 @@
//! The Design panel's Arc fields drive the same `node.set` keys as the canvas handles: Start in
//! degrees, Sweep and Ratio as percentages, and number variables bound to them (sweep and ratio
//! variables hold percentages, like the fields).
use std::f64::consts::{PI, TAU};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn arc(s: &Session, id: NodeId) -> (f64, f64, f64) {
match s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Ellipse { start, sweep, inner } => (start, sweep, inner),
_ => panic!("not an ellipse"),
}
}
fn ellipse(s: &mut Session) -> NodeId {
id_of(&ex(s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap()
}
#[test]
fn fields_write_degrees_and_percentages() {
let mut s = Session::new();
let e = ellipse(&mut s);
// What the fields send for Start 90°, Sweep 75% and Ratio 40%.
ex(&mut s, "node.set", json!({"id": e, "props": {"arcStart": 90, "arcSweep": 75.0 * 3.6, "arcInner": 0.4}}));
let (start, sweep, inner) = arc(&s, e);
assert!((start - PI / 2.0).abs() < 1e-9 && (sweep - 0.75 * TAU).abs() < 1e-9 && (inner - 0.4).abs() < 1e-9);
// A negative sweep runs the other way; past a full turn it holds at one.
ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": -25.0 * 3.6}}));
assert!((arc(&s, e).1 + 0.25 * TAU).abs() < 1e-9);
ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": 900}}));
assert!((arc(&s, e).1 - TAU).abs() < 1e-9);
// Inspect reports the arc the fields show.
ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": 180}}));
let n = ex(&mut s, "node.get", json!({"id": e}));
assert!((n["arc"]["sweep"].as_f64().unwrap() - 180.0).abs() < 1e-9);
// Arc keys are for ellipses only.
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"]).unwrap();
assert!(s.execute("node.set", &json!({"id": r, "props": {"arcSweep": 90}})).is_err());
}
#[test]
fn variables_bound_to_the_arc_hold_what_the_fields_show() {
let mut s = Session::new();
let e = ellipse(&mut s);
ex(&mut s, "variable.createCollection", json!({"name": "Shape", "modes": ["A", "B"]}));
ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "start", "type": "number", "value": 45}));
ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "sweep", "type": "number", "value": 50}));
ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "ratio", "type": "number", "value": 25}));
for (field, var) in [("arcStart", "start"), ("arcSweep", "sweep"), ("arcInner", "ratio")] {
ex(&mut s, "variable.bind", json!({"ids": [e], "field": field, "variable": var}));
}
let (start, sweep, inner) = arc(&s, e);
assert!((start - PI / 4.0).abs() < 1e-9, "degrees: {start}");
assert!((sweep - PI).abs() < 1e-9, "50% of a turn: {sweep}");
assert!((inner - 0.25).abs() < 1e-9, "25%: {inner}");
// A small ratio stays a percentage.
ex(&mut s, "variable.set", json!({"variable": "ratio", "value": 1}));
assert!((arc(&s, e).2 - 0.01).abs() < 1e-9, "{:?}", arc(&s, e));
}

View File

@ -0,0 +1,130 @@
//! Live boolean editing: a boolean group's operands stay layers of their own.
//!
//! - **Selecting operands.** A click selects the boolean group; a double click (or Cmd/Ctrl-click,
//! or a click while a sibling operand is selected) selects the operand under the pointer, and a
//! double click on a selected shape operand enters vector edit mode on it. Enter selects every
//! operand.
//! - **Live results.** Moving, resizing or editing the points of an operand changes the document
//! on every pointer move, so the group's result is redrawn during the drag (one undo step per
//! gesture); a dragged operand stays in its group. The renderer caches every prefix of the group's fold, so only the operands from the
//! edited one up are recombined ([`figcraft_render::boolean`]). While an operand is selected or
//! edited, the canvas outlines every operand of its group ([`outlined_operands`]).
//! - **Per-operand operations.** Each operand above the bottom one combines with the result of the
//! operands below it by its own operation (`Node::bool_op`), or by the group's. Choosing an
//! operation with operands selected sets theirs; with one boolean group selected it sets the
//! group's (and every operand follows it again); otherwise the selection is wrapped in a new
//! boolean group ([`apply`]).
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::params::req_targets;
use crate::{Result, Session};
/// Whether `id` is a boolean group.
pub fn is_group(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::BooleanOp { .. }))
}
/// The boolean group `id` is an operand of.
pub fn group_of(doc: &Document, id: NodeId) -> Option<NodeId> {
let parent = doc.node(id)?.parent?;
is_group(doc, parent).then_some(parent)
}
/// How operand `id` combines with the operands below it: its own operation or its group's
/// (`None` outside boolean groups).
pub fn operand_op(doc: &Document, id: NodeId) -> Option<BoolOp> {
let g = group_of(doc, id)?;
let NodeKind::BooleanOp { op } = doc.node(g)?.kind else { return None };
Some(doc.node(id)?.bool_op.unwrap_or(op))
}
/// An operand above the bottom one whose operation differs from its group's (what the layers
/// panel marks with the operation's icon).
pub fn distinct_op(doc: &Document, id: NodeId) -> Option<BoolOp> {
let g = doc.node(group_of(doc, id)?)?;
let NodeKind::BooleanOp { op } = g.kind else { return None };
let own = doc.node(id)?.bool_op.filter(|o| *o != op)?;
(g.children.first() != Some(&id)).then_some(own)
}
/// The operation the boolean controls show for `ids`: the group's for a lone boolean group, the
/// operands' when they share one (`None` otherwise).
pub fn shown_op(doc: &Document, ids: &[NodeId]) -> Option<BoolOp> {
if let [one] = ids
&& let Some(NodeKind::BooleanOp { op }) = doc.node(*one).map(|n| &n.kind)
{
return Some(*op);
}
let mut ops = ids.iter().map(|i| operand_op(doc, *i));
let first = ops.next()??;
ops.all(|o| o == Some(first)).then_some(first)
}
/// The operands to outline on the canvas while layers inside a boolean group are selected or
/// edited (`editing`: the layer in vector edit mode): every operand of the nearest boolean group
/// holding each of them, in layer order, without repeats.
pub fn outlined_operands(doc: &Document, selection: &[NodeId], editing: Option<NodeId>) -> Vec<NodeId> {
let mut out = Vec::new();
for id in selection.iter().copied().chain(editing) {
let Some(g) = doc.ancestors(id).into_iter().find(|a| is_group(doc, *a)) else { continue };
for c in doc.node(g).map(|n| n.children.clone()).unwrap_or_default() {
if doc.node(c).is_some_and(|n| n.visible) && !out.contains(&c) {
out.push(c);
}
}
}
out
}
/// `bool.union` / `bool.subtract` / `bool.intersect` / `bool.exclude`: see the module docs.
pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result<Value> {
let ids = req_targets(cmd, s, p)?;
let doc = s.doc()?;
let lone_group = match ids.as_slice() {
[one] if is_group(doc, *one) => Some(*one),
_ => None,
};
if let Some(g) = lone_group {
let doc = s.doc_mut()?;
if let NodeKind::BooleanOp { op: o } = &mut doc.get_mut(g)?.kind {
*o = op;
}
doc.get_mut(g)?.name = renamed(&doc.get(g)?.name, op);
for c in doc.get(g)?.children.clone() {
if doc.get(c)?.bool_op.is_some() {
doc.get_mut(c)?.bool_op = None;
}
}
return Ok(json!({"id": g, "operation": op}));
}
if ids.iter().all(|i| group_of(doc, *i).is_some()) {
let doc = s.doc_mut()?;
for i in &ids {
let group_op = match group_of(doc, *i).and_then(|g| doc.node(g)).map(|g| &g.kind) {
Some(NodeKind::BooleanOp { op }) => *op,
_ => continue,
};
doc.get_mut(*i)?.bool_op = (op != group_op).then_some(op);
}
return Ok(json!({"ids": ids, "operation": op}));
}
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
// Operands of a new group follow its operation.
if let Some(g) = crate::params::id_of(&out["id"]) {
let doc = s.doc_mut()?;
for c in doc.get(g)?.children.clone() {
if doc.get(c)?.bool_op.is_some() {
doc.get_mut(c)?.bool_op = None;
}
}
}
Ok(out)
}
/// A boolean group's default name follows its operation; other names stay.
fn renamed(name: &str, op: BoolOp) -> String {
let default = [BoolOp::Union, BoolOp::Subtract, BoolOp::Intersect, BoolOp::Exclude].iter().any(|o| o.label() == name);
if default { op.label().to_string() } else { name.to_string() }
}

View File

@ -0,0 +1,215 @@
//! Live boolean editing: selecting operands on the canvas, results that follow an operand during a
//! drag or a point edit (one undo step each), per-operand operations from the boolean commands
//! and `node.set`, the operands outlined on the canvas, flatten, inspect and junk parameters.
use serde_json::{Value, json};
use crate::Session;
use crate::booleans::{distinct_op, operand_op, outlined_operands, shown_op};
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{BoolOp, NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
}
/// A layer's filled area (a boolean group's result; none of these have holes).
fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs()
}
/// A 100 × 100 square at the origin minus one at (50, 50): (group, bottom, top), nothing
/// selected, snapping off.
fn subtract(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let a = id_of(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
let b = id_of(&ex(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"]).unwrap();
let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap();
ex(s, "select.none", json!({}));
let ds = s.ds_mut().unwrap();
ds.view.snap_objects = false;
ds.view.snap_pixel = false;
(g, a, b)
}
#[test]
fn clicks_select_the_group_and_reach_operands() {
let mut s = Session::new();
let (g, a, b) = subtract(&mut s);
assert!((area(&s, g) - 7500.0).abs() < 1.0);
click(&mut s, 20.0, 20.0, false);
assert_eq!(s.selection(), vec![g], "a click selects the group");
click(&mut s, 20.0, 20.0, true);
assert_eq!(s.selection(), vec![a], "Cmd-click selects the operand");
// With an operand selected, a click on another picks it.
click(&mut s, 120.0, 120.0, false);
assert_eq!(s.selection(), vec![b]);
// A double click goes into the group, then into vector edit mode on the operand.
ex(&mut s, "select.set", json!({"ids": [g]}));
s.pointer(&ev(PointerKind::DoubleClick, 120.0, 120.0, false)).unwrap();
assert_eq!(s.selection(), vec![b], "double click: the operand under the pointer");
s.pointer(&ev(PointerKind::DoubleClick, 120.0, 120.0, false)).unwrap();
let ve = s.ds().unwrap().vector_edit.clone().expect("vector edit on the operand");
assert_eq!(ve.id, b);
assert!(matches!(s.doc().unwrap().node(b).unwrap().kind, NodeKind::Vector { .. }));
assert_eq!(s.doc().unwrap().node(b).unwrap().parent, Some(g), "still an operand");
assert!((area(&s, g) - 7500.0).abs() < 1.0, "converting it changes nothing");
// The canvas outlines every operand of the group while one is edited.
assert_eq!(outlined_operands(s.doc().unwrap(), &s.selection(), Some(b)), vec![a, b]);
assert!(outlined_operands(s.doc().unwrap(), &[g], None).is_empty(), "not for the group itself");
}
#[test]
fn moving_an_operand_updates_the_result_during_the_drag() {
let mut s = Session::new();
let (g, a, _) = subtract(&mut s);
ex(&mut s, "select.set", json!({"ids": [a]}));
s.pointer(&ev(PointerKind::Down, 20.0, 20.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, 30.0, 20.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, 40.0, 20.0, false)).unwrap();
// Mid-drag: A spans 20..120, so the cut is 70 × 50.
assert!((area(&s, g) - 6500.0).abs() < 1.0, "live: {}", area(&s, g));
s.pointer(&ev(PointerKind::Up, 40.0, 20.0, false)).unwrap();
let doc = s.doc().unwrap();
assert!((area(&s, g) - 6500.0).abs() < 1.0, "after: {} {:?} {:?}", area(&s, g), doc.world_bounds(a), doc.node(g).unwrap().children);
// The group's box follows its operands.
let gb = s.doc().unwrap().world_bounds(g).unwrap();
assert!((gb.x0 - 20.0).abs() < 1e-6, "{gb:?}");
// One undo step for the drag.
s.undo().unwrap();
assert!((area(&s, g) - 7500.0).abs() < 1.0);
assert!((s.doc().unwrap().world_bounds(a).unwrap().x0).abs() < 1e-6);
}
#[test]
fn editing_an_operands_points_updates_the_result_live() {
let mut s = Session::new();
let (g, a, _) = subtract(&mut s);
ex(&mut s, "vector.edit.begin", json!({"id": a}));
// Drag A's top-left corner 50 to the left: A becomes a 12,500 trapezoid, the cut stays.
s.pointer(&ev(PointerKind::Down, 0.0, 0.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, -25.0, 0.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, -50.0, 0.0, false)).unwrap();
assert!((area(&s, g) - 10000.0).abs() < 1.0, "live: {}", area(&s, g));
s.pointer(&ev(PointerKind::Up, -50.0, 0.0, false)).unwrap();
assert!((area(&s, g) - 10000.0).abs() < 1.0);
s.undo().unwrap();
assert!((area(&s, g) - 7500.0).abs() < 1.0, "one undo step: {}", area(&s, g));
}
#[test]
fn boolean_commands_set_operand_and_group_operations() {
let mut s = Session::new();
let (g, a, b) = subtract(&mut s);
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "node.reparent", json!({"ids": [c], "parent": g}));
assert_eq!(s.doc().unwrap().node(c).unwrap().parent, Some(g));
// Subtracting the top operand removes it; uniting it adds its 2,500.
assert!((area(&s, g) - 7500.0).abs() < 1.0);
let r = ex(&mut s, "bool.union", json!({"ids": [c]}));
assert_eq!(r["operation"], json!("union"));
assert_eq!(operand_op(s.doc().unwrap(), c), Some(BoolOp::Union));
assert_eq!(distinct_op(s.doc().unwrap(), c), Some(BoolOp::Union));
assert!((area(&s, g) - 10000.0).abs() < 1.0, "{}", area(&s, g));
assert_eq!(s.ds().unwrap().doc.node(g).unwrap().children.len(), 3, "no new group");
// Undo is one step; the operand follows the group again when set to its operation.
s.undo().unwrap();
assert!((area(&s, g) - 7500.0).abs() < 1.0);
s.redo().unwrap();
ex(&mut s, "bool.subtract", json!({"ids": [c]}));
assert_eq!(s.doc().unwrap().node(c).unwrap().bool_op, None);
// The controls show the selection's operation.
ex(&mut s, "bool.intersect", json!({"ids": [b]}));
assert_eq!(shown_op(s.doc().unwrap(), &[b]), Some(BoolOp::Intersect));
assert_eq!(shown_op(s.doc().unwrap(), &[b, c]), None, "mixed");
assert_eq!(shown_op(s.doc().unwrap(), &[g]), Some(BoolOp::Subtract));
// (A ∩ B) − C: the 2,500 overlap.
assert!((area(&s, g) - 2500.0).abs() < 1.0, "{}", area(&s, g));
// One boolean group selected: its operation changes and every operand follows it.
ex(&mut s, "bool.union", json!({"ids": [g]}));
let doc = s.doc().unwrap();
assert!(matches!(doc.node(g).unwrap().kind, NodeKind::BooleanOp { op: BoolOp::Union }));
assert_eq!(doc.node(g).unwrap().name, "Union", "a default name follows the operation");
assert!([a, b, c].iter().all(|i| doc.node(*i).unwrap().bool_op.is_none()));
assert!((area(&s, g) - 20000.0).abs() < 1.0, "{}", area(&s, g));
// Anything else is wrapped in a new group, whose operands follow it.
let d = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10}))["id"]).unwrap();
let r = ex(&mut s, "bool.exclude", json!({"ids": [g, d]}));
let outer = id_of(&r["id"]).unwrap();
assert_ne!(outer, g);
assert_eq!(s.doc().unwrap().node(g).unwrap().parent, Some(outer));
// Flatten bakes the mixed result.
ex(&mut s, "bool.subtract", json!({"ids": [c]}));
let before = area(&s, g);
let r = ex(&mut s, "bool.flatten", json!({"ids": [g]}));
let flat = id_of(&r["id"]).unwrap();
assert!((before - 17500.0).abs() < 1.0, "(A ∪ B) − C, C off to the side: {before}");
assert!((area(&s, flat) - before).abs() < 2.0, "{} vs {before}", area(&s, flat));
}
#[test]
fn node_set_and_inspect_carry_operand_operations() {
let mut s = Session::new();
let (g, a, b) = subtract(&mut s);
ex(&mut s, "node.set", json!({"id": b, "props": {"booleanOperation": "intersect"}}));
assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, Some(BoolOp::Intersect));
let n = ex(&mut s, "node.get", json!({"id": b}));
assert_eq!(n["operandOperation"], json!("intersect"));
ex(&mut s, "node.set", json!({"id": b, "props": {"operandOperation": null}}));
assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, None);
assert!(ex(&mut s, "node.get", json!({"id": b})).get("operandOperation").is_none());
// On the group, booleanOperation is its own.
ex(&mut s, "node.set", json!({"id": g, "props": {"booleanOperation": "exclude"}}));
assert!(matches!(s.doc().unwrap().node(g).unwrap().kind, NodeKind::BooleanOp { op: BoolOp::Exclude }));
// Errors: outside boolean groups, bad names.
let lone = id_of(&ex(&mut s, "shape.ellipse", json!({"x": 300, "y": 300, "width": 10, "height": 10}))["id"]).unwrap();
assert!(s.execute("node.set", &json!({"id": lone, "props": {"booleanOperation": "union"}})).is_err());
assert!(s.execute("node.set", &json!({"id": a, "props": {"operandOperation": "sideways"}})).is_err());
// Ungrouping releases the operands' own operations.
ex(&mut s, "node.set", json!({"id": b, "props": {"operandOperation": "union"}}));
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, None);
}
#[test]
fn boolean_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!({"ids": "x"}),
json!({"ids": [999_999]}),
json!({"ids": [1e300, -1]}),
json!({"id": {"nested": true}}),
json!({"props": {"booleanOperation": [1, 2]}}),
];
for cmd in ["bool.union", "bool.subtract", "bool.intersect", "bool.exclude", "bool.flatten", "node.set"] {
for j in &junk {
let mut s = Session::new();
let (g, _, b) = subtract(&mut s);
ex(&mut s, "select.set", json!({"ids": [b]}));
if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
if let Some(n) = s.doc().unwrap().node(g) {
let _ = figcraft_render::node_outline(s.doc().unwrap(), n);
}
}
}
// node.set with junk operations on an operand.
let mut s = Session::new();
let (_, _, b) = subtract(&mut s);
for v in [json!(7), json!({"op": "union"}), json!(""), json!(true)] {
assert!(s.execute("node.set", &json!({"id": b, "props": {"operandOperation": v}})).is_err(), "{v}");
}
}

View File

@ -32,6 +32,7 @@ pub static MENU: &[MenuItem] = &[
m!("File", SEP, ""),
// FigCraft's own (live sessions): not in the reference menu, so not counted by `parity`.
m!("File", "Join live session…", "collab.join"),
m!("File", "Publish to server…", "collab.publish"),
m!("File", SEP, ""),
m!("File", "Export…", "file.export"),
m!("File", "Export frames to PDF…", "file.exportFramesPdf"),

View File

@ -21,7 +21,8 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
(widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
pub fn snake(v: &Value) -> Value {
@ -117,30 +118,72 @@ fn four(v: &Value) -> Option<[f64; 4]> {
}
}
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)], ...] or [{t, scale}], {preset} | {points}, or null (uniform)";
const BOOL_OP_HELP: &str = "booleanOperation / operandOperation: union|subtract|intersect|exclude (null: the group's, for operands)";
/// A width profile from a preset name, points (`[[t, scale], ...]` or `[{t, scale}]`), `{preset}`
/// / `{points}`, or null. `Some(None)` = uniform; `None` = not a profile.
/// An operand's own boolean operation (`null`: its group's).
fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Result<()> {
if crate::booleans::group_of(doc, id).is_none() {
return Err(bad(cmd, "not a boolean group or a layer in one"));
}
doc.get_mut(id)?.bool_op = if v.is_null() { None } else { Some(enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?) };
Ok(())
}
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)";
/// A width profile from a preset name, points (`[[t, scale] | [t, left, right], ...]` or
/// `[{t, scale} | {t, left, right}]`), `{preset}` / `{points}`, or null. `Some(None)` = uniform;
/// `None` = not a profile.
pub fn width_profile_of(v: &Value) -> Option<Option<WidthProfile>> {
match v {
Value::Null => Some(None),
Value::String(s) => WidthPreset::from_id(s).map(WidthPreset::profile),
Value::Array(a) => {
let pts = a
let num = |x: Option<&Value>| x.and_then(f64_of);
let points = a
.iter()
.map(|p| match p {
Value::Array(xy) => Some((xy.first().and_then(f64_of)?, xy.get(1).and_then(f64_of)?)),
Value::Object(o) => Some((o.get("t").and_then(f64_of)?, o.get("scale").or_else(|| o.get("width")).and_then(f64_of)?)),
Value::Array(xy) => match (num(xy.first()), num(xy.get(1)), num(xy.get(2))) {
(Some(t), Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)),
(Some(t), Some(s), None) => Some(WidthPoint::new(t, s)),
_ => None,
},
Value::Object(o) => {
let t = num(o.get("t"))?;
match (num(o.get("left")), num(o.get("right"))) {
(Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)),
_ => Some(WidthPoint::new(t, num(o.get("scale").or_else(|| o.get("width")))?)),
}
}
_ => None,
})
.collect::<Option<Vec<_>>>()?;
Some(Some(WidthProfile::new(pts)).filter(|p| !p.points.is_empty()))
Some(Some(WidthProfile { points }.sanitized()).filter(|p| !p.points.is_empty()))
}
Value::Object(o) => o.get("points").or_else(|| o.get("preset")).and_then(width_profile_of),
_ => None,
}
}
/// A width point as [`width_profile_of`] reads it back: `[t, scale]`, or `[t, left, right]` when
/// its sides differ.
pub fn width_point_json(p: &WidthPoint) -> Value {
if p.symmetric() {
json!([p.t, p.scale])
} else {
let (l, r) = p.sides();
json!([p.t, l, r])
}
}
/// A profile's points as [`width_profile_of`] reads them back (`null` without points).
pub fn width_points_json(profile: Option<&WidthProfile>) -> Value {
match profile.filter(|p| !p.points.is_empty()) {
Some(p) => Value::Array(p.points.iter().map(width_point_json).collect()),
None => Value::Null,
}
}
const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
/// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`,
@ -597,10 +640,13 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
}
}
"booleanOperation" => match &mut doc.get_mut(id)?.kind {
NodeKind::BooleanOp { op } => *op = enum_of(v).ok_or_else(|| bad(cmd, "booleanOperation: union|subtract|intersect|exclude"))?,
_ => return Err(bad(cmd, "not a boolean group")),
},
"booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?,
"booleanOperation" => {
if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind {
*op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?;
}
}
"operandOperation" => set_operand_op(doc, id, v, cmd)?,
"sectionContentsHidden" => {
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
*contents_hidden = v.as_bool().unwrap_or(false);

View File

@ -37,10 +37,10 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("bool.union", "Union selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Union))),
spec!("bool.subtract", "Subtract selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Subtract))),
spec!("bool.intersect", "Intersect selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Intersect))),
spec!("bool.exclude", "Exclude selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Exclude))),
spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)),
spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)),
spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)),
spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)),
spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten),
spec!("vector.flatten", "Flatten", "{ids?}", true, flatten),
spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke),
@ -103,6 +103,14 @@ enum Wrap {
/// Room a section made around layers leaves on each side.
const SECTION_PAD: f64 = 40.0;
/// What the boolean commands do with what they're given.
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
/// Wrap the selection in a new boolean group doing `op`.
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
wrap(s, p, Wrap::Bool(op))
}
/// Wrap the selection in a new container at the topmost selected layer's place.
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
let ids = req_targets("object.group", s, p)?;
@ -178,6 +186,10 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let op = n.opacity;
for (k, c) in kids.iter().enumerate() {
doc.reparent(*c, parent, Some(idx + k))?;
// Released operands no longer combine with anything.
if doc.get(*c)?.bool_op.is_some() {
doc.get_mut(*c)?.bool_op = None;
}
if op < 1.0 {
let cn = doc.get_mut(*c)?;
cn.opacity *= op;

View File

@ -681,6 +681,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
VariableValue::Color(c) => json!(c.to_css_hex()),
// Opacity variables hold percentages.
VariableValue::Number(x) if field == "opacity" => json!((x / 100.0).clamp(0.0, 1.0)),
// Arc sweep and ratio variables hold percentages too (what the Arc fields show).
VariableValue::Number(x) if field == "arcSweep" => json!(x * 3.6),
VariableValue::Number(x) if field == "arcInner" => json!(x / 100.0),
VariableValue::Number(x) => json!(x),
VariableValue::String(x) => json!(x),
VariableValue::Boolean(b) => json!(b),

View File

@ -6,7 +6,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::tools::ToolId;
use crate::{Result, Session, ViewState, bad, spec};
use crate::{EngineError, Result, Session, ViewState, bad, spec};
fn view(s: &mut Session) -> Result<&mut ViewState> {
Ok(&mut s.ds_mut()?.view)
@ -125,6 +125,10 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
}
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
// A file you can't edit now (view only, or on its way to a server): moving around only.
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
}
s.set_tool(t);
Ok(json!({"tool": t.id()}))
}

View File

@ -689,6 +689,8 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
fresh.dev_changed = root.dev_changed;
fresh.dev_note = root.dev_note.clone();
fresh.constrain_proportions = root.constrain_proportions;
// How the instance combines in a boolean group it sits in is its own.
fresh.bool_op = root.bool_op;
// A swapped nested instance keeps the name its layer was given in the outer main.
let renamed = nested_copy
&& root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind {

View File

@ -53,7 +53,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("dashes".into(), json!(n.stroke.dashes));
}
if let Some(p) = n.stroke.profile() {
let points: Vec<[f64; 2]> = p.points.iter().map(|q| [q.t, q.scale]).collect();
let points = crate::cmd::props::width_points_json(Some(p));
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
}
}
@ -145,6 +145,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
if let Some(slot) = crate::components::slot_name(doc, n.id) {
o.insert("slot".into(), json!(slot));
}
// An operand combining by an operation of its own.
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
o.insert("operandOperation".into(), json!(op));
}
}
match &n.kind {
NodeKind::Text(t) => {

View File

@ -10,6 +10,7 @@
pub mod al_keys;
pub mod annotations;
pub mod arc_handles;
pub mod booleans;
pub mod catalog;
pub mod cmd;
pub mod codegen;
@ -630,7 +631,8 @@ impl Session {
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v));
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
match &result {
Ok(_) => {
self.after_command(spec, before);
@ -667,6 +669,17 @@ impl Session {
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
}
/// A live session's document you may only view (or that is on its way to a server) is
/// read-only: refuse an edit (it is rolled back).
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
match self.live_read_only(*uid) {
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) => Err(EngineError::Disabled(id.to_string(), why.into())),
_ => Ok(()),
}
}
/// Instances can't gain, lose or reorder layers, and library copies are read-only: refuse such
/// an edit (it is rolled back).
fn check_instance_structure(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
@ -741,6 +754,9 @@ impl Session {
/// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot.
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
textpreview::end(self);
if let Some(why) = self.active_read_only() {
return Err(EngineError::Disabled(label.to_string(), why.into()));
}
let ds = self.ds_mut()?;
if ds.preview.is_some() {
return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into()));
@ -1133,12 +1149,16 @@ mod annotation_categories_tests;
#[cfg(test)]
mod annotations_tests;
#[cfg(test)]
mod arc_fields_tests;
#[cfg(test)]
mod arc_handles_tests;
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod collab_ids_tests;
#[cfg(test)]
mod collab_live_tests;
@ -1179,6 +1199,8 @@ mod keymap_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod live_access_tests;
#[cfg(test)]
mod live_tests;
#[cfg(test)]
mod mask_tests;
@ -1225,6 +1247,8 @@ mod section_tests;
#[cfg(test)]
mod selection_depth_tests;
#[cfg(test)]
mod shapebuilder_strokes_tests;
#[cfg(test)]
mod shapebuilder_tests;
#[cfg(test)]
mod slot_tests;

View File

@ -16,6 +16,11 @@
//! through the client and installed with [`Session::apply_remote`], which never touches the undo
//! history. After a dropped connection the client resumes with its id and catches up; what it
//! made meanwhile goes out then.
//!
//! A file can also live on a `figcraft-server`: `collab.publish` uploads the open document and
//! syncs that tab through the server, whose share links let others edit or only view. A view link
//! makes the synced document read-only here (every edit is refused before it is sent; the server
//! refuses it too).
use std::collections::BTreeMap;
use std::net::IpAddr;
@ -23,6 +28,8 @@ use std::sync::Arc;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, EditingText, ServerMsg};
pub use figcraft_collab::message::Access;
use figcraft_doc::{Document, NodeId};
use serde::Serialize;
use serde_json::{Value, json};
@ -32,6 +39,7 @@ pub use figcraft_collab::message::{Peer, Presence};
use crate::cmd::CommandSpec;
use crate::collab::EngineRecompute;
use crate::params::{get, num, req_string, string};
use crate::tools::ToolId;
use crate::{DocState, EngineError, Result, Session, TextEdit, ViewState, bad, spec};
/// Most selected ids one presence update carries (the rest aren't shown to others).
@ -127,10 +135,28 @@ pub struct SessionInfo {
/// invite.
pub link: String,
pub url: String,
/// `ip:port` the session listens on (host) or connects to (guest).
/// `ip:port` the session listens on (host) or connects to (guest); a server's URL when it
/// isn't plain `ws://` at `/`.
pub address: String,
/// Reachable from other machines (the host listens on a network address, not loopback).
pub network: bool,
/// What you may do: edit, or only view (a server's view link).
pub access: Access,
}
/// A file you published to a server: where, and its first share links (they carry their tokens:
/// share them like passwords; the server keeps only hashes).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Published {
/// The server's address.
pub server: String,
/// The file's id and name on the server.
pub file: String,
pub name: String,
/// The link that lets people edit, and the one that lets them only view.
pub edit: String,
pub view: String,
}
/// What happened in the session since the last [`LiveLink::poll`].
@ -152,6 +178,8 @@ pub enum Event {
Ended {
reason: String,
},
/// The document is on the server (`collab.publish`); joining it follows.
Published(Published),
}
/// Which network interface a hosted session listens on.
@ -177,6 +205,21 @@ pub struct HostOptions {
pub doc: Arc<Document>,
}
/// What [`LiveLink::publish`] needs to put the open file on a server.
#[derive(Clone, Debug)]
pub struct PublishOptions {
/// The server's address (`ws://host:port/`, `wss://host/path/`), which may carry its key
/// (`?key=…`).
pub server: String,
/// The server's key, when the address doesn't carry it.
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
/// Your name in the session (empty: the server picks one).
pub me: String,
pub doc: Arc<Document>,
}
/// The networking behind live sessions, installed by the app ([`Live::install`]). Calls return
/// at once; what happens later arrives through [`poll`](Self::poll).
pub trait LiveLink: Send {
@ -189,6 +232,12 @@ pub trait LiveLink: Send {
/// Start joining the session behind an invite link (ending any current one). Errors are for
/// links that can't be used; they never echo the link (it holds the token).
fn join(&mut self, link: &str, name: &str) -> std::result::Result<(), String>;
/// Start uploading `opts.doc` to a server as a new file, then join it with its edit link
/// (ending any current session): [`Event::Published`] with its links, then the welcome.
/// Errors are for addresses that can't be used; they never echo the key.
fn publish(&mut self, _opts: PublishOptions) -> std::result::Result<(), String> {
Err("publishing to a server needs the desktop app or the MCP server".into())
}
/// End the current session (stop hosting or leave).
fn leave(&mut self);
/// Your latest presence (the link throttles and coalesces).
@ -238,6 +287,8 @@ pub struct Live {
/// Registers the app as the opener of `figcraft://` invite links (the desktop app on Windows
/// installs one; see [`crate::invite_links`]).
pub links: Option<Box<dyn crate::invite_links::LinkRegistrar>>,
/// The file you published to a server in this session, with its links.
pub published: Option<Published>,
}
impl Live {
@ -274,6 +325,7 @@ impl Live {
self.synced = None;
self.deferred.clear();
self.hold = 0;
self.published = None;
}
/// The document sync client (once the welcome arrived): peers, pending batches, the user's
@ -349,6 +401,11 @@ pub fn editing_hint(names: &[&str]) -> Option<String> {
})
}
/// Why an edit of a server file you joined with a view link is refused.
pub const VIEW_ONLY: &str = "View only: the link you joined with lets you see this file, not change it";
/// Why an edit of a file on its way to a server is refused.
pub const PUBLISHING: &str = "The file is being published: you can edit it again once it is on the server";
/// A gesture, version preview or live text preview shows a document that isn't the edit to
/// sync yet.
fn busy(ds: &DocState) -> bool {
@ -504,6 +561,10 @@ impl Session {
self.message = Some(reason);
live.reset();
}
Event::Published(p) => {
self.message = Some(format!("Published {} to the server", p.name));
live.published = Some(p);
}
}
}
@ -533,8 +594,19 @@ impl Session {
self.live.synced = None;
}
(Some(Role::Guest), Some(uid)) => {
// Joining again in this tab, or the file you published arriving from the server.
let doc = Arc::new(client.document().clone());
let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty);
self.apply_remote(uid, doc.clone());
if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) {
// Undo is per user from now on: earlier whole-document steps would revert
// others' edits.
ds.history.undo.clear();
ds.history.redo.clear();
if !was_dirty {
ds.mark_saved();
}
}
self.live.synced = Some(doc);
}
(Some(Role::Guest), None) => {
@ -552,6 +624,10 @@ impl Session {
info.file_name = doc.name.clone();
info.pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
}
// A view link: the editing tools are off.
if client.access() == Access::View && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) {
self.set_tool(ToolId::Move);
}
self.live.client = Some(client);
self.live.deferred.clear();
}
@ -573,6 +649,25 @@ impl Session {
self.live_edit(None);
}
/// Why document `uid` can't be edited now, if it can't: it is the session's document and you
/// may only view it, or it is on its way to a server.
pub fn live_read_only(&self, uid: u64) -> Option<&'static str> {
if self.live.doc_uid != Some(uid) {
return None;
}
match &self.live.client {
Some(c) if c.access() == Access::View => Some(VIEW_ONLY),
Some(_) => None,
None if self.live.status.active() && self.live.role() != Some(Role::Host) => Some(PUBLISHING),
None => None,
}
}
/// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)).
pub fn active_read_only(&self) -> Option<&'static str> {
self.ds().ok().and_then(|ds| self.live_read_only(ds.uid))
}
/// Whether document `uid` is the live session's synced document: its edits sync and its undo
/// history is the user's own.
pub fn live_doc(&self, uid: u64) -> bool {
@ -588,6 +683,13 @@ impl Session {
if self.live.hold > 0 || busy(ds) || self.live.synced.as_ref().is_some_and(|s| Arc::ptr_eq(s, &ds.doc)) {
return;
}
if self.live_read_only(uid).is_some() {
// Nothing goes out: what changed outside a command is put back.
if let Some(s) = self.live.synced.clone() {
self.apply_remote(uid, s);
}
return;
}
let doc = ds.doc.clone();
let Some(client) = self.live.client.as_mut() else { return };
match client.edit_with(&doc, label) {
@ -793,6 +895,9 @@ impl Session {
"url": info.map(|i| i.url.as_str()),
"address": info.map(|i| i.address.as_str()),
"network": info.map(|i| i.network),
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
"readOnly": self.active_read_only().is_some(),
"published": l.published,
"follow": l.follow,
"syncing": l.syncing(),
"document": l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)),
@ -831,6 +936,23 @@ fn host(s: &mut Session, p: &Value) -> Result<Value> {
Ok(s.live_summary())
}
fn publish(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.publish";
let server = req_string(CMD, p, "server")?;
let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::<String>()).filter(|n| !n.is_empty());
let opts =
PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me: display_name(&s.prefs), doc };
let link = s.live.link.as_mut().ok_or_else(|| unavailable(CMD))?;
link.publish(opts).map_err(|e| bad(CMD, e))?;
s.live.reset();
s.live.status = Status::Connecting;
// This tab becomes the server file's synced document once it is there.
s.live.doc_uid = Some(uid);
Ok(s.live_summary())
}
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.join";
let link_text = req_string(CMD, p, "link")?;
@ -844,10 +966,14 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
let was = s.live.status.active();
let kept = s.live.syncing() && s.live.role() == Some(Role::Guest);
if let Some(l) = s.live.link.as_mut() {
l.leave();
}
s.live.reset();
if kept {
s.message = Some("You left the session; your copy stays open here".into());
}
Ok(json!({"ended": was}))
}
@ -887,15 +1013,22 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.join",
"Join live session",
"{link: figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…} → status \"connecting\"; `collab.peers` tells when it's live",
"{link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link)} → status \"connecting\"; `collab.peers` tells when it's live",
false,
join
),
spec!("collab.leave", "Leave live session", "{} → {ended}", false, leave),
spec!("collab.leave", "Leave live session", "{} (the synced file stays open as a local copy) → {ended}", false, leave),
spec!(
"collab.publish",
"Publish to server",
"{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view}` (share links: edit, or view only)",
false,
publish
),
spec!(
"collab.peers",
"People in this file",
"{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
"{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
false,
|s, _| Ok(s.live_summary())
),

View File

@ -0,0 +1,127 @@
//! Files on a server, against a scripted link: `collab.publish` makes the open tab the server
//! file's synced document (read-only until it is there, its undo history per person after), and
//! a view link makes the synced document read-only (commands, gestures, editing tools and changes
//! made outside a command are all refused or put back; others' edits still arrive; other tabs stay
//! editable). Leaving keeps the file open as your copy.
use figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_collab::{Client, NoRecompute, Outgoing};
use serde_json::json;
use crate::live::{Access, Event, PUBLISHING, Published, Role, VIEW_ONLY};
use crate::live_tests::{info, push, relay, serve_as, session};
use crate::tools::ToolId;
use crate::{EngineError, Session};
fn refused(s: &mut Session, cmd: &str, params: serde_json::Value, why: &str) {
match s.execute(cmd, &params) {
Err(EngineError::Disabled(_, reason)) => assert_eq!(reason, why, "{cmd}"),
other => panic!("{cmd}: expected a refusal, got {other:?}"),
}
}
#[test]
fn publishing_makes_the_open_tab_the_server_file() {
let (mut s, script) = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
assert!(s.undo_depth().0 > 0);
let e = s.execute("collab.publish", &json!({"server": "nope"})).unwrap_err();
assert!(e.to_string().contains("not a server address"), "{e}");
let r = s.execute("collab.publish", &json!({"server": " ws://127.0.0.1:47900/?key=k ", "name": " Team\n"})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(r["readOnly"], true);
{
let sc = script.lock().unwrap();
assert_eq!(sc.published.len(), 1);
assert_eq!(sc.published[0].server, "ws://127.0.0.1:47900/?key=k");
assert_eq!(sc.published[0].name, "Team");
assert_eq!(sc.published[0].doc.node_count(), s.doc().unwrap().node_count());
}
// Until it is on the server nothing changes it (and the name defaults to the file's).
refused(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 5, "height": 5}), PUBLISHING);
refused(&mut s, "tool.set", json!({"tool": "rectangle"}), PUBLISHING);
assert!(s.execute("tool.set", &json!({"tool": "hand"})).is_ok(), "moving around is fine");
assert!(s.begin_interaction("Move").is_err());
// The server's welcome: this tab is the synced document, undo is per person from now on.
let mut doc = s.doc().unwrap().clone();
doc.name = "Team".into();
let (mut server, id, welcome) = serve_as(&doc, Access::Edit);
let published = Published { server: "127.0.0.1:47900".into(), file: "f".into(), name: "Team".into(), edit: "e".into(), view: "v".into() };
push(&script, vec![Event::Published(published.clone()), Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, welcome]);
s.live_poll();
assert!(s.live.syncing());
assert_eq!(s.docs.len(), 1, "no new tab");
assert_eq!(s.doc().unwrap().name, "Team");
assert_eq!(s.undo_depth(), (0, 0));
assert_eq!(s.active_read_only(), None);
assert_eq!(s.live.published, Some(published));
assert_eq!(s.message.as_deref(), Some("Published Team to the server"));
assert_eq!(s.live_summary()["published"]["name"], "Team");
// Edits sync through the server.
let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
let answers = relay(&script, &mut server, id);
assert!(server.document().contains(figcraft_doc::NodeId(rect)));
push(&script, answers);
s.live_poll();
assert_eq!(s.undo_depth(), (1, 0));
// Leaving keeps the file open, yours to edit.
s.execute("collab.leave", &json!({})).unwrap();
assert_eq!(s.docs.len(), 1);
assert!(s.doc().unwrap().contains(figcraft_doc::NodeId(rect)));
assert_eq!(s.message.as_deref(), Some("You left the session; your copy stays open here"));
assert!(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
}
#[test]
fn a_view_link_makes_the_file_read_only() {
let (mut s, script) = session();
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
let mut doc = figcraft_doc::Document::new("Shared");
let page = doc.first_page().unwrap();
let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
let (mut server, id, welcome) = serve_as(&doc, Access::View);
let mut me = info(Role::Guest, id);
me.access = Access::View;
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, welcome]);
s.live_poll();
assert!(s.live.syncing());
assert_eq!(s.active_read_only(), Some(VIEW_ONLY));
let summary = s.live_summary();
assert_eq!((summary["access"].as_str(), summary["readOnly"].as_bool()), (Some("view"), Some(true)));
assert_eq!(s.tool.tool, ToolId::Move, "the editing tool was put away");
// Commands, tools and gestures are refused; nothing changes and nothing is sent.
let before = s.doc().unwrap().clone();
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}), VIEW_ONLY);
refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}), VIEW_ONLY);
refused(&mut s, "edit.delete", json!({"ids": [card.0]}), VIEW_ONLY);
refused(&mut s, "tool.set", json!({"tool": "text"}), VIEW_ONLY);
assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..))));
assert_eq!(*s.doc().unwrap(), before);
assert!(s.execute("select.set", &json!({"ids": [card.0]})).is_ok(), "selecting is fine");
// A change made outside a command is put back, never sent.
s.ds_mut().unwrap().doc_mut().node_mut(card).unwrap().name = "Outside".into();
s.live_sync();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Card");
assert!(script.lock().unwrap().msgs.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
// Someone who may edit renames the card: it arrives.
let ClientMsg::Hello(h) = Client::hello("f", "", "Bo", "") else { panic!() };
let (bo_id, outs) = server.join(h).unwrap();
let Some(Outgoing::To(_, w)) = outs.into_iter().next() else { panic!() };
let mut bo = Client::join(w).unwrap();
let mut d = bo.document().clone();
d.node_mut(card).unwrap().name = "Bo's card".into();
let m = bo.edit(&d).unwrap().unwrap();
for o in server.receive(bo_id, m) {
if let Outgoing::All(m @ ServerMsg::Changes(_)) = o {
bo.receive(m.clone(), &NoRecompute).unwrap();
push(&script, vec![Event::Sync(m)]);
}
}
s.live_poll();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Bo's card");
// Your other tabs stay yours.
s.active = 0;
assert_eq!(s.active_read_only(), None);
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
}

View File

@ -8,7 +8,7 @@ use figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_doc::{Document, NodeId};
use serde_json::json;
use crate::live::{Event, HostOptions, LiveLink, Peer, Presence, Role, SessionInfo, Status};
use crate::live::{Access, Event, HostOptions, LiveLink, Peer, Presence, PublishOptions, Role, SessionInfo, Status};
use crate::{EngineError, Session};
/// What the scripted link was asked to do, and the events it hands out next.
@ -20,6 +20,7 @@ pub(crate) struct Script {
joined: Vec<String>,
left: usize,
pub(crate) msgs: Vec<ClientMsg>,
pub(crate) published: Vec<PublishOptions>,
}
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
@ -36,6 +37,13 @@ impl LiveLink for Mock {
self.0.lock().unwrap().joined.push(link.to_string());
Ok(())
}
fn publish(&mut self, opts: PublishOptions) -> Result<(), String> {
if !opts.server.starts_with("ws") {
return Err("not a server address".into());
}
self.0.lock().unwrap().published.push(opts);
Ok(())
}
fn leave(&mut self) {
self.0.lock().unwrap().left += 1;
}
@ -62,6 +70,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
url: "ws://127.0.0.1:47900/?file=f&token=t".into(),
address: "127.0.0.1:47900".into(),
network: false,
access: crate::live::Access::Edit,
}
}
@ -69,28 +78,33 @@ fn peer(id: u32, name: &str) -> Peer {
Peer { client_id: id, name: name.into(), color: "#9747FF".into() }
}
fn session() -> (Session, Arc<Mutex<Script>>) {
pub(crate) fn session() -> (Session, Arc<Mutex<Script>>) {
let mut s = Session::new();
let script = Arc::new(Mutex::new(Script::default()));
s.live.install(Box::new(Mock(script.clone())));
(s, script)
}
fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
pub(crate) fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
script.lock().unwrap().events.extend(events);
}
/// A session server for `doc` and the welcome it gives a first client (as `Event::Sync`).
fn serve(doc: &Document) -> (Server, u32, Event) {
serve_as(doc, Access::Edit)
}
/// [`serve`] for a client that may do `access`.
pub(crate) fn serve_as(doc: &Document, access: Access) -> (Server, u32, Event) {
let mut server = Server::new(doc.clone(), "", Limits::default());
let ClientMsg::Hello(h) = Client::hello("f", "", "Me", "") else { panic!("no hello") };
let (id, outs) = server.join(h).unwrap();
let (id, outs) = server.join_as(h, access).unwrap();
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
(server, id, Event::Sync(w))
}
/// Feed what the session sent to the server; the server's answers for the session, in order.
fn relay(script: &Arc<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
pub(crate) fn relay(script: &Arc<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
let msgs = std::mem::take(&mut script.lock().unwrap().msgs);
let mut out = Vec::new();
for m in msgs {

View File

@ -6,6 +6,13 @@
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
//! when nothing is left), so the rest of the drawing stays exactly where it was.
//!
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
//! the band a stroke paints is a region too. When a stroked shape takes part in a merge or a
//! removal, its stroke becomes an outlined vector (filled with the stroke's paints) just above it,
//! so both keep looking the same; open paths, which have no fill, are replaced by that outline. A
//! region merged from a stroke is filled with the stroke's paints.
//!
//! With the Shape builder tool (Shift+M) the canvas highlights the region under the pointer; a
//! click or a drag across regions merges them, and Alt (or the tool's Remove mode) removes them.
//! Each gesture is one command, so one undo step. The arrangement is cached per document version
@ -25,6 +32,13 @@ use crate::params::*;
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId};
use crate::{EngineError, Result, Session, bad, spec};
/// One input of the arrangement: a shape's fill area, or its stroke's outlined area.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub struct Part {
pub id: NodeId,
pub stroke: bool,
}
/// One region of the arrangement, in page coordinates.
#[derive(Clone, Debug)]
pub struct Region {
@ -32,6 +46,8 @@ pub struct Region {
pub path: BezPath,
/// The shapes covering it, top-most first.
pub sources: Vec<NodeId>,
/// The fills and strokes covering it, top-most first.
pub parts: Vec<Part>,
pub bounds: Rect,
/// The outline flattened to polygons (outer first), for drawing.
pub polys: Vec<Vec<Point>>,
@ -47,6 +63,8 @@ pub struct Arrangement {
ids: Vec<NodeId>,
/// The shapes taking part, bottom to top.
pub sources: Vec<NodeId>,
/// Their fills and strokes, bottom to top (a shape's stroke above its fill).
pub parts: Vec<Part>,
pub regions: Vec<Region>,
}
@ -64,11 +82,11 @@ impl Arrangement {
}
}
/// Can `id` take part? Visible, unlocked shapes, boolean groups and text with an area, outside
/// instances and boolean groups (whose structure is fixed).
/// Can `id` take part? Visible, unlocked shapes (lines and open paths through their strokes),
/// boolean groups and text, outside instances and boolean groups (whose structure is fixed).
fn usable(doc: &Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
let kind_ok = (n.kind.is_shape() && !matches!(n.kind, NodeKind::Line)) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
let kind_ok = n.kind.is_shape() || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
kind_ok
&& n.visible
&& !n.locked
@ -98,11 +116,19 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out
}
/// A shape's fill area in page coordinates and whether it fills even-odd.
fn world_outline(doc: &Document, id: NodeId) -> Option<(BezPath, bool)> {
let n = doc.node(id)?;
let (mut p, rule) = figcraft_render::node_outline(doc, n)?;
p.apply_affine(doc.world_transform(id));
/// A part's area in page coordinates and whether it fills even-odd: the shape's fill area (closed
/// shapes, boolean groups and text) or its visible stroke's outlined area.
fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
let n = doc.node(part.id)?;
let (mut p, rule) = if part.stroke {
figcraft_render::stroke_fill(doc, n).map(|(p, rule)| ((*p).clone(), rule))?
} else {
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
return None;
}
figcraft_render::node_outline(doc, n)?
};
p.apply_affine(doc.world_transform(part.id));
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
}
@ -125,11 +151,20 @@ fn polygons(path: &BezPath, bounds: Rect) -> Vec<Vec<Point>> {
fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement> {
let mut sources = Vec::new();
let mut parts = Vec::new();
let mut inputs = Vec::new();
for id in eligible(&doc, ids) {
if let Some(o) = world_outline(&doc, id) {
let before = parts.len();
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
&& let Some(o) = world_area(&doc, part)
{
parts.push(part);
inputs.push(o);
}
}
if parts.len() > before {
sources.push(id);
inputs.push(o);
}
}
let faces = figcraft_pathops::faces(&inputs).map_err(|e| EngineError::Other(format!("shape builder: {e}")))?;
@ -138,8 +173,16 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
.filter(|f| f.path.area().abs() > 1e-4)
.map(|f| {
let bounds = f.path.bounding_box();
let covering: Vec<Part> = f.cover.iter().rev().filter_map(|i| parts.get(*i).copied()).collect();
let mut by: Vec<NodeId> = Vec::new();
for p in &covering {
if !by.contains(&p.id) {
by.push(p.id);
}
}
Region {
sources: f.cover.iter().rev().filter_map(|i| sources.get(*i).copied()).collect(),
sources: by,
parts: covering,
polys: polygons(&f.path, bounds),
point: figcraft_doc::vector_faces::interior_point(&f.path),
bounds,
@ -149,7 +192,7 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
.collect();
// Reading order: top to bottom, then left to right.
regions.sort_by(|a, b| a.bounds.y0.total_cmp(&b.bounds.y0).then(a.bounds.x0.total_cmp(&b.bounds.x0)));
Ok(Arrangement { doc, uid, ids: ids.to_vec(), sources, regions })
Ok(Arrangement { doc, uid, ids: ids.to_vec(), sources, parts, regions })
}
/// The arrangement of `ids` (default: the selection), from the cache when the document and the
@ -230,9 +273,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
crate::vectoredit::refit(doc, id)
}
/// A new vector for the page-space area `area`, styled like `style`, just above it.
fn create_merged(doc: &mut Document, style: NodeId, area: &BezPath) -> Result<NodeId> {
let src = doc.get(style)?.clone();
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
/// with the stroke's paints), just above its shape.
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
let mut src = doc.get(style.id)?.clone();
if style.stroke {
src.fills = std::mem::take(&mut src.strokes);
src.stroke = StrokeProps::default();
src.styles.fill = src.styles.stroke.take();
}
let style = style.id;
let parent = src.parent.ok_or_else(|| EngineError::Other("no parent".into()))?;
let pw = doc.world_transform(parent);
if !(pw.determinant().is_finite() && pw.determinant().abs() > 1e-12) {
@ -266,7 +316,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
return Err(bad(cmd, "no region picked: pass `regions` (indexes from shapeBuilder.regions) or `points` inside regions"));
}
let regions: Vec<&Region> = pick.iter().filter_map(|i| a.regions.get(*i)).collect();
let touched: BTreeSet<NodeId> = regions.iter().flat_map(|r| r.sources.iter().copied()).collect();
let touched_parts: BTreeSet<Part> = regions.iter().flat_map(|r| r.parts.iter().copied()).collect();
let touched: BTreeSet<NodeId> = touched_parts.iter().map(|p| p.id).collect();
// Merging every region of a single shape would only replace it with a copy of itself.
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
@ -276,40 +327,73 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
let doc = s.doc_mut()?;
let created = if merge {
let area = figcraft_pathops::union_faces(regions.iter().map(|r| &r.path)).map_err(|e| EngineError::Other(e.to_string()))?;
let style = regions.first().and_then(|r| r.sources.first().copied()).ok_or_else(|| bad(cmd, "the region has no shape"))?;
let style = regions.first().and_then(|r| r.parts.first().copied()).ok_or_else(|| bad(cmd, "the region has no shape"))?;
Some(create_merged(doc, style, &area)?)
} else {
None
};
let mut removed = Vec::new();
let mut changed = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
// What part `part` keeps: the regions it covers that weren't picked, as one area (`None`:
// nothing).
let kept = |part: Part| -> Result<Option<BezPath>> {
let keep: Vec<&BezPath> =
a.regions.iter().enumerate().filter(|(i, r)| !pick.contains(i) && r.sources.contains(&src)).map(|(_, r)| &r.path).collect();
a.regions.iter().enumerate().filter(|(i, r)| !pick.contains(i) && r.parts.contains(&part)).map(|(_, r)| &r.path).collect();
if keep.is_empty() {
doc.remove(src)?;
removed.push(src);
continue;
return Ok(None);
}
let area = figcraft_pathops::union_faces(keep).map_err(|e| EngineError::Other(e.to_string()))?;
if area.elements().is_empty() {
doc.remove(src)?;
removed.push(src);
Ok((!area.elements().is_empty()).then_some(area))
};
let mut removed = Vec::new();
let mut changed = Vec::new();
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
if a.parts.contains(&stroke) {
if let Some(area) = kept(stroke)? {
outlined.push((src, create_merged(doc, stroke, &area)?));
}
let n = doc.get_mut(src)?;
n.strokes.clear();
n.styles.stroke = None;
}
let left = if !a.parts.contains(&fill) {
None
} else if touched_parts.contains(&fill) {
kept(fill)?
} else {
reshape(doc, src, &area)?;
// The fill wasn't picked: the shape stays as it is.
changed.push(src);
continue;
};
match left {
Some(area) => {
reshape(doc, src, &area)?;
changed.push(src);
}
None => {
doc.remove(src)?;
removed.push(src);
}
}
}
// The selection stays (minus what went) and gains the new shape unless a selected group
// already holds it, so the tool keeps working on the same shapes.
// The selection stays (minus what went, plus the outlines of selected strokes) and gains the
// new shape unless a selected group already holds it, so the tool keeps working on the same
// shapes.
let mut sel: Vec<NodeId> = ids.iter().copied().filter(|i| !removed.contains(i)).collect();
for (src, o) in &outlined {
if ids.contains(src) {
sel.push(*o);
}
}
if let Some(c) = created
&& !doc.ancestors(c).iter().any(|x| sel.contains(x))
{
sel.push(c);
}
s.set_selection(sel);
Ok(json!({"id": created, "changed": changed, "removed": removed, "regions": pick}))
let outlines: Vec<NodeId> = outlined.iter().map(|(_, o)| *o).collect();
Ok(json!({"id": created, "changed": changed, "removed": removed, "outlines": outlines, "regions": pick}))
}
pub fn specs() -> Vec<CommandSpec> {
@ -317,7 +401,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"shapeBuilder.regions",
"Shape builder regions",
"{ids?} → {sources (bottom to top), regions: [{index, path (SVG, page), sources (top-most first), bounds [x, y, w, h], point [x, y] inside}]}",
"{ids?} → {sources (bottom to top), regions: [{index, path (SVG, page), sources (top-most first), parts: [{id, stroke}] (the fills and outlined strokes covering it, top-most first), bounds [x, y, w, h], point [x, y] inside}]} — visible strokes take part as their outlined areas, so stroked lines and open paths split regions",
false,
|s, p| {
let ids = targets(s, p);
@ -331,6 +415,7 @@ pub fn specs() -> Vec<CommandSpec> {
"index": i,
"path": r.path.to_svg(),
"sources": r.sources,
"parts": r.parts,
"bounds": [r.bounds.x0, r.bounds.y0, r.bounds.width(), r.bounds.height()],
"point": r.point.map(|q| [q.x, q.y]),
})
@ -342,14 +427,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"shapeBuilder.merge",
"Merge regions",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} — one new shape from the regions, styled like the top-most shape over the first; the shapes lose those areas",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
true,
|s, p| apply(s, "shapeBuilder.merge", p, true)
),
spec!(
"shapeBuilder.remove",
"Remove regions",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} — the shapes covering the regions lose them",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {changed, removed, outlines, regions} — the shapes (and strokes) covering the regions lose them",
true,
|s, p| apply(s, "shapeBuilder.remove", p, false)
),

View File

@ -0,0 +1,156 @@
//! The shape builder with strokes and open paths: strokes take part as their outlined areas, open
//! paths split the regions they cross, merging a stroke's region fills it with the stroke's paints,
//! and the strokes of the shapes involved become outlined vectors.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// A layer's filled area in page units.
fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
let (mut p, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
p.apply_affine(doc.world_transform(id));
p.area().abs()
}
fn fill_hex(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
}
/// A red 100 × 100 square and, above it, a black 10 wide open path across its middle from x = -20
/// to 120; both selected.
fn square_and_path(s: &mut Session) -> (NodeId, NodeId) {
let r = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
let l = id(&ex(s, "vector.create", json!({"points": [[-20, 50], [120, 50]], "closed": false})));
ex(s, "node.set", json!({"id": l, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
s.set_selection(vec![r, l]);
(r, l)
}
#[test]
fn a_stroked_open_path_splits_the_regions_it_crosses() {
let mut s = Session::new();
let (r, l) = square_and_path(&mut s);
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(out["sources"], json!([r, l]));
let regions = out["regions"].as_array().unwrap();
// Above and below the path, the band across the square, and the path's two ends outside it.
assert_eq!(regions.len(), 5, "{out}");
let covered = |x: f64, y: f64| -> Value {
regions
.iter()
.find(|g| {
let b = &g["bounds"];
let (x0, y0) = (b[0].as_f64().unwrap(), b[1].as_f64().unwrap());
x >= x0 && y >= y0 && x <= x0 + b[2].as_f64().unwrap() && y <= y0 + b[3].as_f64().unwrap()
})
.map(|g| g["parts"].clone())
.unwrap_or(Value::Null)
};
assert_eq!(covered(50.0, 20.0), json!([{"id": r, "stroke": false}]));
assert_eq!(covered(50.0, 50.0), json!([{"id": l, "stroke": true}, {"id": r, "stroke": false}]), "the stroke is above the fill");
assert_eq!(covered(-10.0, 50.0), json!([{"id": l, "stroke": true}]));
// A path without a visible stroke paints nothing and takes no part.
ex(&mut s, "node.set", json!({"id": l, "props": {"strokes": []}}));
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(out["sources"], json!([r]));
assert_eq!(out["regions"].as_array().unwrap().len(), 1);
}
#[test]
fn merging_a_stroke_region_fills_it_with_the_stroke() {
let mut s = Session::new();
let (r, l) = square_and_path(&mut s);
let out = ex(&mut s, "shapeBuilder.merge", json!({"points": [[50, 50]]}));
let m = id(&out);
// The band, black (the stroke's paint), no stroke of its own.
assert!((area(&s, m) - 1000.0).abs() < 1.0, "{}", area(&s, m));
assert_eq!(fill_hex(&s, m), "000000");
assert!(s.doc().unwrap().node(m).unwrap().strokes.is_empty());
// The square lost the band; the path (no fill) became an outline of what was left of its
// stroke: the two ends outside the square.
assert!((area(&s, r) - 9000.0).abs() < 1.0, "{}", area(&s, r));
assert_eq!(out["removed"], json!([l]));
let o = id_of(&out["outlines"][0]).unwrap();
assert!((area(&s, o) - 400.0).abs() < 1.0, "{}", area(&s, o));
assert_eq!(fill_hex(&s, o), "000000");
assert!(matches!(s.doc().unwrap().node(o).unwrap().kind, NodeKind::Vector { .. }));
// The selection keeps working: the square, the outline and the new shape.
let sel = s.selection();
assert!(sel.contains(&r) && sel.contains(&o) && sel.contains(&m), "{sel:?}");
// One undo step.
s.undo().unwrap();
let doc = s.doc().unwrap();
assert!(doc.contains(l) && !doc.contains(m) && !doc.contains(o));
assert!((area(&s, r) - 10000.0).abs() < 1.0);
}
#[test]
fn removing_regions_spares_strokes_that_arent_involved() {
let mut s = Session::new();
let (r, l) = square_and_path(&mut s);
let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 20]]}));
// The square keeps the band and the part below; the path isn't touched.
assert!((area(&s, r) - 5500.0).abs() < 1.0, "{}", area(&s, r));
assert_eq!(out["outlines"], json!([]));
let n = s.doc().unwrap().node(l).unwrap();
assert!(!n.strokes.is_empty() && matches!(n.kind, NodeKind::Vector { .. }));
// Removing an end of the path leaves the rest of its stroke as an outline.
let out = ex(&mut s, "shapeBuilder.remove", json!({"ids": [r, l], "points": [[-10, 50]]}));
assert_eq!(out["removed"], json!([l]));
let o = id_of(&out["outlines"][0]).unwrap();
assert!((area(&s, o) - (1000.0 + 200.0)).abs() < 1.0, "the band and the other end: {}", area(&s, o));
}
#[test]
fn a_stroked_shape_gives_its_ring_and_keeps_its_look() {
let mut s = Session::new();
// An inside stroke 10 wide: a ring inside the square's edge.
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#0000ff"], "strokeWeight": 10, "strokeAlign": "inside"}}));
s.set_selection(vec![r]);
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(out["regions"].as_array().unwrap().len(), 2, "the middle and the ring: {out}");
// Removing the middle leaves the fill under the ring and the ring as a blue outline above it.
let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 50]]}));
assert_eq!(out["changed"], json!([r]));
assert!((area(&s, r) - 3600.0).abs() < 1.0, "{}", area(&s, r));
assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty());
let o = id_of(&out["outlines"][0]).unwrap();
assert!((area(&s, o) - 3600.0).abs() < 1.0);
assert_eq!(fill_hex(&s, o), "0000FF");
let doc = s.doc().unwrap();
let parent = doc.node(r).unwrap().parent.unwrap();
let kids = &doc.node(parent).unwrap().children;
assert!(kids.iter().position(|k| *k == o) > kids.iter().position(|k| *k == r), "the outline sits above the shape");
}
#[test]
fn stroke_regions_survive_junk() {
for j in [
json!({"points": [[f64::MAX, 0]]}),
json!({"regions": [-1, 1e20, "x"]}),
json!({"ids": [123_456], "points": [[50, 50]]}),
json!({"points": "x"}),
] {
for cmd in ["shapeBuilder.regions", "shapeBuilder.merge", "shapeBuilder.remove"] {
let mut s = Session::new();
let _ = square_and_path(&mut s);
if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}

View File

@ -225,7 +225,7 @@ fn groups_rotated_and_ineligible_layers() {
let line = id(&ex(&mut s, "shape.line", json!({"x": 0, "y": 5, "width": 20})));
let frame = id(&ex(&mut s, "frame.create", json!({"x": 100, "y": 100, "width": 50, "height": 50})));
let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": [g, line, frame]}));
assert_eq!(r["sources"], json!([a, b]), "groups give their shapes; lines and frames don't take part");
assert_eq!(r["sources"], json!([a, b, line]), "groups give their shapes; lines take part through their strokes; frames don't");
// The merged shape lands in the group, which stays selected.
s.set_selection(vec![g]);
let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"points": [[7, 7]]})));

View File

@ -1016,9 +1016,10 @@ impl Session {
let p = ev.p();
let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent);
// Absolute children of an auto layout frame stay in it, and so do the layers of
// an instance (outside its slots: they only move); the rest may change parent.
// an instance (outside its slots: they only move) and the operands of a boolean
// group (its result follows them); the rest may change parent.
let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute))
&& !home.is_some_and(|h| crate::components::structure_locked(doc, h));
&& !home.is_some_and(|h| crate::components::structure_locked(doc, h) || crate::booleans::is_group(doc, h));
let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) };
let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode);
if let (true, Some(mode)) = (flow, mode) {
@ -1410,9 +1411,11 @@ impl Session {
let before = ds.doc.clone();
let d = ds.doc_mut();
for m in &moving {
// Instance layers stay put (outside slots): they only move.
// Instance layers (outside slots) and boolean operands stay put: they only move.
let from = d.node(*m).and_then(|n| n.parent);
if from.is_some_and(|f| crate::components::structure_locked(d, f)) || crate::components::structure_locked(d, target) {
if from.is_some_and(|f| crate::components::structure_locked(d, f) || crate::booleans::is_group(d, f))
|| crate::components::structure_locked(d, target)
{
continue;
}
// A layer can't go into itself; it stays where it is.
@ -1506,9 +1509,8 @@ impl Session {
if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) {
return crate::vectoredit::begin(self, target);
}
// Groups/frames: select one level deeper (the child under the pointer).
let deeper =
select_target(&doc, leaf, &[select_target(&doc, leaf, &sel, false)].into_iter().chain(sel.iter().copied()).collect::<Vec<_>>(), false);
// Groups, frames and boolean groups: select one level deeper (the layer under the pointer).
let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel);
if deeper != target || !sel.contains(&deeper) {
self.set_selection(vec![deeper]);
}

View File

@ -16,6 +16,8 @@ pub mod transform;
mod transform_tests;
mod width;
#[cfg(test)]
mod width_sides_tests;
#[cfg(test)]
mod width_tests;
use figcraft_doc::*;

View File

@ -3,6 +3,10 @@
//! dragging a point slides it along the path, dragging one of its handles changes its width;
//! Delete removes the selected point. Every gesture is one undo step.
//!
//! **One-sided widths:** with Alt held, dragging a handle (or the path) changes the width on that
//! side of the path only; a plain handle drag makes both sides as wide again. The sides are left
//! and right looking along the path (see [`WidthPoint`]).
//!
//! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are
//! fractions of each subpath's length).
@ -29,10 +33,11 @@ const TOL_PX: f64 = 6.0;
pub enum WHit {
/// A width point on the path (drag slides it along).
Point { index: usize },
/// One of a width point's handles (drag sets its width).
Handle { index: usize },
/// The path between points (drag adds a point there); `t` is the position along it.
Path { t: f64 },
/// One of a width point's handles (drag sets its width; with Alt, that side's only).
Handle { index: usize, side: WidthSide },
/// The path between points (drag adds a point there); `t` is the position along it and `side`
/// the side of the path the pointer is on.
Path { t: f64, side: WidthSide },
}
/// A width point as drawn: on the path and its two handles, in page coordinates, per subpath.
@ -41,9 +46,12 @@ pub enum WHit {
pub struct WidthHandle {
pub index: usize,
pub at: [f64; 2],
/// The handles on the right and the left of the path.
pub sides: [[f64; 2]; 2],
/// The stroke's full width there (local units).
pub width: f64,
/// Whether the two sides differ.
pub one_sided: bool,
}
/// An in-progress Variable width gesture.
@ -52,7 +60,7 @@ pub(crate) enum WidthGesture {
/// Slide point `index` along the path.
Slide { index: usize, moved: bool },
/// Set the width of point `index`; `added` = the gesture added it at `t`.
Size { index: usize, added: Option<f64>, moved: bool },
Size { index: usize, added: Option<f64>, moved: bool, side: WidthSide },
}
/// The edited node's stroke path flattened (local coordinates) with its weight and profile.
@ -68,24 +76,31 @@ fn stroked(doc: &Document, id: NodeId) -> Option<Stroked> {
Some(Stroked { lines: polylines(&path)?, weight: n.stroke.weight, profile: n.stroke.profile().cloned() })
}
/// Width handles for `(index, t, scale)` on every subpath, as drawn at `zoom` (page coordinates).
fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator<Item = (usize, f64, f64)> + Clone, zoom: f64) -> Vec<WidthHandle> {
/// Width handles for `(index, t, (left, right) side scales)` on every subpath, as drawn at `zoom`
/// (page coordinates).
fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator<Item = (usize, f64, (f64, f64))> + Clone, zoom: f64) -> Vec<WidthHandle> {
let w = doc.world_transform(id);
let min = HANDLE_MIN_PX / zoom.max(1e-6);
let mut out = Vec::new();
for line in &st.lines {
for (index, t, scale) in pts.clone() {
for (index, t, (left, right)) in pts.clone() {
let Some((at, n)) = line.at(t) else { continue };
let half = 0.5 * st.weight * scale;
let (hl, hr) = (0.5 * st.weight * left, 0.5 * st.weight * right);
let c = w * at;
let dir = w * (at + n) - c;
let dir = if dir.hypot() > 1e-12 { dir / dir.hypot() } else { Vec2::new(0.0, 1.0) };
let reach = ((w * (at + n * half)) - c).hypot().max(min);
let side = |s: f64| {
let side = |half: f64, s: f64| {
let reach = ((w * (at + n * half)) - c).hypot().max(min);
let q = c + dir * (reach * s);
[q.x, q.y]
};
out.push(WidthHandle { index, at: [c.x, c.y], sides: [side(1.0), side(-1.0)], width: 2.0 * half });
out.push(WidthHandle {
index,
at: [c.x, c.y],
sides: [side(hr, 1.0), side(hl, -1.0)],
width: hl + hr,
one_sided: (left - right).abs() > 1e-9,
});
}
}
out
@ -95,14 +110,14 @@ fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator<Item = (
pub fn width_handles(doc: &Document, ve: &super::VectorEdit, zoom: f64) -> Vec<WidthHandle> {
let Some(st) = stroked(doc, ve.id) else { return vec![] };
let Some(profile) = st.profile.clone() else { return vec![] };
handles(doc, ve.id, &st, profile.points.iter().enumerate().map(|(i, p)| (i, p.t, p.scale)), zoom)
handles(doc, ve.id, &st, profile.points.iter().enumerate().map(|(i, p)| (i, p.t, p.sides())), zoom)
}
/// Where a width point added at `t` would go, with the width there (the hover preview).
pub fn width_preview(doc: &Document, ve: &super::VectorEdit, t: f64, zoom: f64) -> Vec<WidthHandle> {
let Some(st) = stroked(doc, ve.id) else { return vec![] };
let scale = st.profile.as_ref().map_or(1.0, |p| p.scale_at(t));
handles(doc, ve.id, &st, std::iter::once((usize::MAX, t, scale)), zoom)
let sides = st.profile.as_ref().map_or((1.0, 1.0), |p| p.sides_at(t));
handles(doc, ve.id, &st, std::iter::once((usize::MAX, t, sides)), zoom)
}
/// Hit test the Variable width tool at page point `p`: handles, then points, then the path.
@ -114,7 +129,10 @@ pub fn width_hit(doc: &Document, ve: &super::VectorEdit, p: Point, zoom: f64) ->
handles.iter().map(|h| (h.index, pick(h))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
};
if let Some(index) = best(&|h| near(h.sides[0]).min(near(h.sides[1]))) {
return Some(WHit::Handle { index });
// The nearer of its handles (across subpaths).
let d = |k: usize| handles.iter().filter(|h| h.index == index).map(|h| near(h.sides[k])).fold(f64::INFINITY, f64::min);
let side = if d(0) <= d(1) { WidthSide::Right } else { WidthSide::Left };
return Some(WHit::Handle { index, side });
}
if let Some(index) = best(&|h| near(h.at)) {
return Some(WHit::Point { index });
@ -122,15 +140,21 @@ pub fn width_hit(doc: &Document, ve: &super::VectorEdit, p: Point, zoom: f64) ->
// Anywhere on the painted stroke counts, or near its centre line when it's thin.
let st = stroked(doc, ve.id)?;
let w = doc.world_transform(ve.id);
let (sub, t, _) = nearest(&st.lines, w.inverse() * p)?;
let (q, _) = st.lines.get(sub)?.at(t)?;
let half = 0.5 * st.weight * st.profile.as_ref().map_or(1.0, |pr| pr.scale_at(t)) * w.determinant().abs().sqrt();
((w * q - p).hypot() <= tol.max(half)).then_some(WHit::Path { t })
let lp = w.inverse() * p;
let (sub, t, _) = nearest(&st.lines, lp)?;
let (q, n) = st.lines.get(sub)?.at(t)?;
let side = if (lp - q).dot(n) >= 0.0 { WidthSide::Right } else { WidthSide::Left };
let scale = st.profile.as_ref().map_or(1.0, |pr| {
let (l, r) = pr.sides_at(t);
if side == WidthSide::Right { r } else { l }
});
let half = 0.5 * st.weight * scale * w.determinant().abs().sqrt();
((w * q - p).hypot() <= tol.max(half)).then_some(WHit::Path { t, side })
}
/// Write node `id`'s width profile (through `node.set`'s key, so instances record the override).
fn put_profile(doc: &mut Document, id: NodeId, profile: Option<&WidthProfile>) -> Result<()> {
let v = profile.filter(|p| !p.points.is_empty()).map_or(Value::Null, |p| json!(p.points.iter().map(|q| [q.t, q.scale]).collect::<Vec<_>>()));
let v = crate::cmd::props::width_points_json(profile);
crate::cmd::props::apply("vector.edit.widthPoint", doc, id, &serde_json::Map::from_iter([("widthProfile".to_string(), v)]))
}
@ -139,10 +163,25 @@ fn profile_of(doc: &Document, id: NodeId) -> WidthProfile {
doc.node(id).and_then(|n| n.stroke.profile()).cloned().unwrap_or_else(WidthProfile::uniform)
}
/// Local point `lp` seen from point `t` of the path (the nearest subpath's point there): how far
/// out it is along the normal (negative to the left) and the side it is on.
fn pointer_at(st: &Stroked, t: f64, lp: Point) -> Option<(f64, WidthSide)> {
let (q, n) = st.lines.iter().filter_map(|l| l.at(t)).min_by(|a, b| (a.0 - lp).hypot().total_cmp(&(b.0 - lp).hypot()))?;
let along = (lp - q).dot(n);
Some((along, if along >= 0.0 { WidthSide::Right } else { WidthSide::Left }))
}
/// The width scale for a handle dragged to local point `lp` from point `t` (the nearest
/// subpath's point there counts).
fn scale_at_pointer(st: &Stroked, t: f64, lp: Point) -> Option<f64> {
let d = st.lines.iter().filter_map(|l| l.at(t)).map(|(q, _)| (q - lp).hypot()).fold(f64::INFINITY, f64::min);
/// subpath's point there counts). With `side`, the scale of that side only: how far the pointer
/// is out to that side (0 past the centre line).
fn scale_at_pointer(st: &Stroked, t: f64, lp: Point, side: Option<WidthSide>) -> Option<f64> {
let d = match side {
None => st.lines.iter().filter_map(|l| l.at(t)).map(|(q, _)| (q - lp).hypot()).fold(f64::INFINITY, f64::min),
Some(s) => {
let (along, _) = pointer_at(st, t, lp)?;
(along * if s == WidthSide::Right { 1.0 } else { -1.0 }).max(0.0)
}
};
(d.is_finite() && st.weight > 0.0).then(|| 2.0 * d / st.weight)
}
@ -151,7 +190,13 @@ fn width_point(s: &mut Session, p: &Value) -> Result<Value> {
let ve = editing(s)?;
let doc = s.doc()?;
let weight = doc.get(ve.id)?.stroke.weight;
let scale = num(p, "scale").or_else(|| num(p, "width").filter(|_| weight > 0.0).map(|w| w / weight));
let side = match string(p, "side") {
Some(v) => Some(WidthSide::from_id(&v).ok_or_else(|| bad(cmd, "side: left|right"))?),
None => None,
};
// A side's width runs from the centre line to that edge: half of what both sides make.
let per_px = if side.is_some() { 2.0 } else { 1.0 };
let scale = num(p, "scale").or_else(|| num(p, "width").filter(|_| weight > 0.0).map(|w| per_px * w / weight));
let action = string(p, "action").unwrap_or_else(|| if get(p, "index").is_some() { "set".into() } else { "add".into() });
let mut prof = profile_of(doc, ve.id);
if action != "add" && doc.get(ve.id)?.stroke.profile().is_none() {
@ -160,12 +205,22 @@ fn width_point(s: &mut Session, p: &Value) -> Result<Value> {
let selected = match action.as_str() {
"add" => {
let t = req_num(cmd, p, "t")?;
let sc = scale.unwrap_or_else(|| prof.scale_at(t));
Some(prof.insert(t, sc))
let i = match (scale, side) {
(Some(sc), None) => prof.insert(t, sc),
_ => prof.insert_here(t),
};
if let (Some(sc), Some(sd)) = (scale, side) {
prof.set_side(i, sd, sc);
}
Some(i)
}
"set" | "move" => {
let i = req_index(cmd, p, "index")?;
if !prof.set_point(i, num(p, "t"), scale) {
let ok = match side {
Some(sd) => prof.set_point(i, num(p, "t"), None) && scale.is_none_or(|sc| prof.set_side(i, sd, sc)),
None => prof.set_point(i, num(p, "t"), scale),
};
if !ok {
return Err(bad(cmd, "no such width point"));
}
Some(i)
@ -189,7 +244,7 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
vec![spec!(
"vector.edit.widthPoint",
"Width point",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
true,
width_point
)]
@ -211,17 +266,17 @@ impl Session {
return Ok(());
}
let g = match h {
Some(WHit::Handle { index }) => WidthGesture::Size { index, added: None, moved: false },
Some(WHit::Handle { index, side }) => WidthGesture::Size { index, added: None, moved: false, side },
Some(WHit::Point { index }) => WidthGesture::Slide { index, moved: false },
Some(WHit::Path { t }) => {
Some(WHit::Path { t, side }) => {
self.begin_interaction("Variable width")?;
let mut index = 0;
self.preview(|doc, _| {
let mut prof = profile_of(doc, id);
index = prof.insert(t, prof.scale_at(t));
index = prof.insert_here(t);
put_profile(doc, id, Some(&prof))
})?;
WidthGesture::Size { index, added: Some(t), moved: false }
WidthGesture::Size { index, added: Some(t), moved: false, side }
}
None => {
return set_state(self, |e| e.width_point = None);
@ -249,18 +304,31 @@ impl Session {
})?;
WidthGesture::Slide { index, moved: true }
}
WidthGesture::Size { index, added, .. } => {
WidthGesture::Size { index, added, side, .. } => {
// A handle keeps its side; a point being added widens the side the pointer is on.
let mut side = side;
self.preview(|doc, _| {
let Some(st) = stroked(doc, id) else { return Ok(()) };
let mut prof = profile_of(doc, id);
if let Some(t) = added {
prof.insert(t, prof.scale_at(t));
prof.insert_here(t);
}
let Some(t) = prof.points.get(index).map(|q| q.t) else { return Ok(()) };
prof.set_point(index, None, scale_at_pointer(&st, t, lp));
if added.is_some()
&& let Some((_, s)) = pointer_at(&st, t, lp)
{
side = s;
}
// Alt: that side only; otherwise both sides as wide.
let one_side = ev.alt.then_some(side);
let scale = scale_at_pointer(&st, t, lp, one_side);
match (one_side, scale) {
(Some(s), Some(sc)) => prof.set_side(index, s, sc),
_ => prof.set_point(index, None, scale),
};
put_profile(doc, id, Some(&prof))
})?;
WidthGesture::Size { index, added, moved: true }
WidthGesture::Size { index, added, moved: true, side }
}
};
self.tool.gesture = Gesture::Vector(super::VGesture::Width(g));

View File

@ -0,0 +1,167 @@
//! One-sided widths with the Variable width tool: Alt-dragging a handle or the path changes one
//! side, a plain drag evens them out, the width point command's `side`, `widthProfile` points with
//! two sides, and what outline stroke and export make of them.
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
use crate::tools::{PointerEvent, PointerKind};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent {
PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: bool) {
s.pointer(&ev(PointerKind::Down, from.0, from.1, alt)).unwrap();
s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, alt)).unwrap();
s.pointer(&ev(PointerKind::Move, to.0, to.1, alt)).unwrap();
s.pointer(&ev(PointerKind::Up, to.0, to.1, alt)).unwrap();
}
/// An open path from (0, 50) to (100, 50) (its right side is below it) with a 10 wide centred
/// stroke, in vector edit mode with the Variable width tool.
fn path(s: &mut Session) -> NodeId {
let r = ex(s, "vector.create", json!({"points": [[0, 50], [100, 50]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(s, "node.set", json!({"id": id, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
ex(s, "vector.edit.begin", json!({"id": id}));
ex(s, "vector.edit.tool", json!({"tool": "width"}));
id
}
/// The side scales of width point `i`, rounded.
fn sides(s: &Session, id: NodeId, i: usize) -> (f64, f64) {
let p = s.doc().unwrap().node(id).unwrap().stroke.profile().unwrap().points[i];
let (l, r) = p.sides();
((l * 1e6).round() / 1e6, (r * 1e6).round() / 1e6)
}
fn ve(s: &Session) -> VectorEdit {
s.ds().unwrap().vector_edit.clone().unwrap()
}
#[test]
fn alt_dragging_a_handle_changes_that_side_only() {
let mut s = Session::new();
let id = path(&mut s);
// A point 40 wide in the middle: 20 either side.
ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 40}));
assert_eq!(sides(&s, id, 1), (4.0, 4.0));
s.pointer(&ev(PointerKind::Hover, 50.0, 70.0, true)).unwrap();
assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1, side: WidthSide::Right }));
// Alt-drag the right handle in to 10 from the centre: the left keeps its 20.
drag(&mut s, (50.0, 70.0), (50.0, 60.0), true);
assert_eq!(sides(&s, id, 1), (4.0, 2.0));
let hs = width_handles(s.doc().unwrap(), &ve(&s), 1.0);
let h = hs.iter().find(|h| h.index == 1).unwrap();
assert!(h.one_sided && (h.sides[0][1] - 60.0).abs() < 1e-6 && (h.sides[1][1] - 30.0).abs() < 1e-6, "{h:?}");
assert!((h.width - 30.0).abs() < 1e-6);
// The painted stroke follows: 20 above, 10 below in the middle.
let b = s.doc().unwrap().world_render_bounds(id).unwrap();
assert!(b.y0 <= 30.0 + 1e-6, "{b:?}");
// One undo step; a plain drag makes both sides as wide again.
s.undo().unwrap();
assert_eq!(sides(&s, id, 1), (4.0, 4.0));
s.redo().unwrap();
drag(&mut s, (50.0, 30.0), (50.0, 35.0), false);
assert_eq!(sides(&s, id, 1), (3.0, 3.0));
// Alt past the centre line leaves that side at nothing.
drag(&mut s, (50.0, 35.0), (50.0, 55.0), true);
assert_eq!(sides(&s, id, 1), (0.0, 3.0));
}
#[test]
fn alt_dragging_the_path_adds_a_point_widening_the_side_the_pointer_goes() {
let mut s = Session::new();
let id = path(&mut s);
// From the path at a quarter of the way, up (the left side) to 15 out.
drag(&mut s, (25.0, 50.0), (25.0, 35.0), true);
let pts = s.doc().unwrap().node(id).unwrap().stroke.profile().unwrap().points.clone();
assert_eq!(pts.len(), 3);
assert_eq!(sides(&s, id, 1), (3.0, 1.0), "{pts:?}");
s.undo().unwrap();
assert!(s.doc().unwrap().node(id).unwrap().stroke.profile().is_none(), "adding and sizing is one step");
}
#[test]
fn the_width_point_command_sets_one_side() {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 20}));
// `width` with a side: the distance from the centre line to that edge.
ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "side": "left", "width": 15}));
assert_eq!(sides(&s, id, 1), (3.0, 2.0));
ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "side": "right", "scale": 0.5}));
assert_eq!(sides(&s, id, 1), (3.0, 0.5));
// Adding with a side widens that side of what is there.
let r = ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.9, "side": "right", "width": 25}));
let i = r["index"].as_u64().unwrap() as usize;
let (l, rr) = sides(&s, id, i);
assert!((rr - 5.0).abs() < 1e-6 && l > 0.9 && l < 3.0, "{l} {rr}");
// State and inspect report two-sided points as [t, left, right].
let n = ex(&mut s, "node.get", json!({"id": id}));
let p1: Vec<f64> = n["widthProfile"]["points"][1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(p1.len() == 3 && [0.5, 3.0, 0.5].iter().zip(&p1).all(|(a, b)| (a - b).abs() < 1e-9), "{p1:?}");
// A width without a side evens the point out again.
ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "width": 10}));
assert_eq!(sides(&s, id, 1), (1.0, 1.0));
assert!(s.execute("vector.edit.widthPoint", &json!({"index": 1, "side": "up", "width": 3})).is_err());
}
#[test]
fn profiles_with_two_sides_round_trip_and_outline() {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "vector.edit.end", json!({}));
// [t, left, right] and {t, left, right}: all of the width below the path.
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": [[0, 0, 2], {"t": 1, "left": 0, "right": 2}]}}));
assert_eq!(sides(&s, id, 0), (0.0, 2.0));
assert_eq!(sides(&s, id, 1), (0.0, 2.0));
let back = ex(&mut s, "node.get", json!({"id": id}))["widthProfile"].clone();
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": null}}));
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": back}}));
assert_eq!(sides(&s, id, 1), (0.0, 2.0));
// Exports render it; outline stroke makes a vector 10 tall, all below the path.
let doc = s.doc().unwrap().clone();
for f in [ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Png] {
assert!(!crate::cmd::file::export_bytes(&doc, &[id], f, 1.0).unwrap().is_empty());
}
let r = ex(&mut s, "vector.outlineStroke", json!({"ids": [id]}));
let v = crate::params::id_of(&r["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(v).unwrap();
assert!((b.y0 - 50.0).abs() < 0.05 && (b.y1 - 60.0).abs() < 0.05 && (b.width() - 100.0).abs() < 0.05, "{b:?}");
}
#[test]
fn one_sided_widths_survive_junk() {
let junk = [
json!({"index": 1, "side": 7, "width": 1}),
json!({"index": 1, "side": "left", "width": "NaN"}),
json!({"index": 1, "side": "right", "scale": 1e308}),
json!({"t": 0.5, "side": "left", "scale": -1e9}),
json!({"index": 99, "side": "left", "width": 1}),
];
for j in &junk {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 20}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute("vector.edit.widthPoint", j) {
panic!("{cmd} panicked on {j}: {msg}");
}
let p = s.doc().unwrap().node(id).unwrap().stroke.profile().cloned().unwrap();
assert!(p.points.iter().all(|q| q.scale.is_finite() && q.bias.is_finite() && (-1.0..=1.0).contains(&q.bias)), "{j}: {p:?}");
}
for v in [json!([[0, 1, "x"]]), json!([{"t": 0, "left": 1}]), json!([[0, -5, 1e308], [1, f64::NAN, 2]])] {
let mut s = Session::new();
let id = path(&mut s);
let _ = s.execute("node.set", &json!({"id": id, "props": {"widthProfile": v}}));
if let Some(p) = s.doc().unwrap().node(id).unwrap().stroke.profile() {
assert!(p.points.iter().all(|q| q.scale.is_finite() && q.scale >= 0.0 && q.bias.is_finite()));
}
}
}

View File

@ -79,7 +79,7 @@ fn width_tool_drags_add_slide_and_size_points_in_one_step_each() {
assert_eq!(ve(&s).tool, VTool::Width);
// Hovering the stroke offers to add a point there.
s.pointer(&ev(PointerKind::Hover, 50.0, 52.0)).unwrap();
assert!(matches!(s.tool.overlay.width_hover, Some(WHit::Path { t }) if (t - 0.5).abs() < 1e-9));
assert!(matches!(s.tool.overlay.width_hover, Some(WHit::Path { t, side: WidthSide::Right }) if (t - 0.5).abs() < 1e-9));
// Dragging from the path adds a point and sets its width: 20 from the centre = 40 wide.
drag(&mut s, (50.0, 50.0), (50.0, 70.0));
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 4.0), (1.0, 1.0)]);
@ -94,7 +94,7 @@ fn width_tool_drags_add_slide_and_size_points_in_one_step_each() {
let h = hs.iter().find(|h| h.index == 1).unwrap();
assert!((h.sides[0][1] - 70.0).abs() < 1e-6 || (h.sides[1][1] - 70.0).abs() < 1e-6, "{h:?}");
s.pointer(&ev(PointerKind::Hover, 50.0, 30.0)).unwrap();
assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1 }));
assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1, side: WidthSide::Left }));
drag(&mut s, (50.0, 30.0), (50.0, 45.0));
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 1.0), (1.0, 1.0)]);
// Dragging the point slides it along the path.

View File

@ -6,6 +6,10 @@
//! dashes, caps, joins, arrowheads and variable width profiles ([`width`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(test)]
mod ops_tests;
#[cfg(test)]
mod sides_tests;
pub mod width;
#[cfg(test)]
mod width_tests;
@ -79,44 +83,37 @@ pub fn simplify(path: &BezPath) -> Result<BezPath, PathOpsError> {
binary(path, &BezPath::new(), linesweeper::BinaryOp::Union)
}
/// Combine `acc` (the operands so far) with the next operand `p` by `op`.
pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result<BezPath, PathOpsError> {
let op = match op {
BoolOp::Union => linesweeper::BinaryOp::Union,
BoolOp::Intersect => linesweeper::BinaryOp::Intersection,
BoolOp::Exclude => linesweeper::BinaryOp::Xor,
BoolOp::Subtract => linesweeper::BinaryOp::Difference,
};
binary(acc, p, op)
}
/// Apply a boolean operation to `paths` in order (bottom to top, like a boolean group's children):
/// Subtract removes every later shape from the first one.
pub fn boolean(paths: &[BezPath], op: BoolOp) -> Result<BezPath, PathOpsError> {
let mut it = paths.iter();
let Some(first) = it.next() else { return Ok(BezPath::new()) };
let mut acc = first.clone();
match op {
BoolOp::Union => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Union)?;
}
}
BoolOp::Intersect => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Intersection)?;
}
}
BoolOp::Exclude => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Xor)?;
}
}
BoolOp::Subtract => {
let rest: Vec<BezPath> = it.cloned().collect();
if rest.is_empty() {
return Ok(acc);
}
let mut cut = BezPath::new();
for p in &rest {
cut = binary(&cut, p, linesweeper::BinaryOp::Union)?;
}
acc = binary(&acc, &cut, linesweeper::BinaryOp::Difference)?;
}
boolean_ops(paths.iter().map(|p| (p, op)))
}
/// Fold `operands` bottom to top, each combined with the result of those below it by its own
/// operation (the bottom operand's operation is unused): what a boolean group whose operands carry
/// their own operations paints. A lone operand comes out simplified.
pub fn boolean_ops<'a>(operands: impl IntoIterator<Item = (&'a BezPath, BoolOp)>) -> Result<BezPath, PathOpsError> {
let mut it = operands.into_iter();
let Some((first, _)) = it.next() else { return Ok(BezPath::new()) };
let mut acc: Option<BezPath> = None;
for (p, op) in it {
acc = Some(boolean_step(acc.as_ref().unwrap_or(first), p, op)?);
}
if paths.len() == 1 {
return simplify(&acc);
match acc {
Some(a) => Ok(a),
None => simplify(first),
}
Ok(acc)
}
/// Per-input winding numbers for [`faces`] (index = input path; trailing zeros trimmed so equal

View File

@ -0,0 +1,83 @@
//! Boolean groups whose operands carry their own operations: the fold bottom to top, its
//! agreement with a uniform operation, and single steps.
use figcraft_doc::BoolOp;
use figcraft_geom::Rect;
use figcraft_geom::kurbo::{BezPath, Shape};
use crate::{boolean, boolean_ops, boolean_step};
fn sq(x: f64, y: f64, s: f64) -> BezPath {
Rect::new(x, y, x + s, y + s).to_path(0.1)
}
fn area(p: &BezPath) -> f64 {
p.area().abs()
}
#[test]
fn each_operand_combines_with_the_result_below_it() {
let (a, b, c) = (sq(0.0, 0.0, 10.0), sq(5.0, 0.0, 10.0), sq(0.0, 0.0, 15.0));
// (A ∪ B) ∩ C: the union spans x 0..15, y 0..10 (150), all inside C.
let r = boolean_ops([(&a, BoolOp::Union), (&b, BoolOp::Union), (&c, BoolOp::Intersect)]).unwrap();
assert!((area(&r) - 150.0).abs() < 0.5, "{}", area(&r));
// (A ∪ B) − a 5 × 5 square in the middle.
let hole = sq(5.0, 2.5, 5.0);
let r = boolean_ops([(&a, BoolOp::Union), (&b, BoolOp::Union), (&hole, BoolOp::Subtract)]).unwrap();
assert!((area_eo(&r) - 125.0).abs() < 0.5, "{}", area_eo(&r));
// The bottom operand's own operation is unused.
let r2 = boolean_ops([(&a, BoolOp::Exclude), (&b, BoolOp::Union), (&hole, BoolOp::Subtract)]).unwrap();
assert!((area_eo(&r2) - 125.0).abs() < 0.5);
}
/// Even-odd area: contours nested in another count negative (holes).
fn area_eo(p: &BezPath) -> f64 {
let mut total = 0.0;
let subs: Vec<BezPath> = split(p);
for (i, s) in subs.iter().enumerate() {
let inside = subs.iter().enumerate().filter(|(j, o)| *j != i && o.bounding_box().contains_rect(s.bounding_box())).count();
total += if inside % 2 == 0 { area(s) } else { -area(s) };
}
total
}
fn split(p: &BezPath) -> Vec<BezPath> {
use figcraft_geom::kurbo::PathEl;
let mut out: Vec<BezPath> = Vec::new();
for el in p.elements() {
if matches!(el, PathEl::MoveTo(_)) {
out.push(BezPath::new());
}
if let Some(last) = out.last_mut() {
last.push(*el);
}
}
out
}
#[test]
fn a_uniform_fold_matches_the_group_operation() {
let (a, b, c) = (sq(0.0, 0.0, 10.0), sq(5.0, 5.0, 10.0), sq(8.0, 0.0, 4.0));
for op in [BoolOp::Union, BoolOp::Subtract, BoolOp::Intersect, BoolOp::Exclude] {
let all = boolean(&[a.clone(), b.clone(), c.clone()], op).unwrap();
let folded = boolean_ops([(&a, op), (&b, op), (&c, op)]).unwrap();
assert!((area_eo(&all) - area_eo(&folded)).abs() < 1e-6, "{op:?}");
}
// Subtract removes every later operand from the first.
let s = boolean(&[a.clone(), b.clone(), c.clone()], BoolOp::Subtract).unwrap();
assert!((area_eo(&s) - (100.0 - 25.0 - 8.0)).abs() < 0.5, "{}", area_eo(&s));
}
#[test]
fn single_steps_and_edge_cases() {
let (a, b) = (sq(0.0, 0.0, 10.0), sq(5.0, 5.0, 10.0));
assert!((area(&boolean_step(&a, &b, BoolOp::Intersect).unwrap()) - 25.0).abs() < 0.5);
assert!((area(&boolean_step(&a, &b, BoolOp::Subtract).unwrap()) - 75.0).abs() < 0.5);
// Nothing, and a lone operand (simplified).
assert!(boolean_ops(std::iter::empty()).unwrap().elements().is_empty());
assert!((area(&boolean_ops([(&a, BoolOp::Subtract)]).unwrap()) - 100.0).abs() < 1e-6);
let mut bad = BezPath::new();
bad.move_to((f64::NAN, 0.0));
bad.line_to((1.0, 1.0));
assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err());
}

View File

@ -0,0 +1,70 @@
//! One-sided variable widths in stroke outlines: each side offset by its own width, lopsided
//! caps around the middle of the end, dashes and closed paths.
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeProps, WidthPoint, WidthProfile};
use figcraft_geom::kurbo::{BezPath, Shape};
use crate::stroke_outline;
/// A horizontal line from (0, 0) to (100, 0): looking along it, its right side is +y (y down).
fn line() -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((100.0, 0.0));
p
}
fn props(points: Vec<WidthPoint>) -> StrokeProps {
StrokeProps { weight: 10.0, align: StrokeAlign::Center, width_profile: Some(WidthProfile { points }.sanitized()), ..Default::default() }
}
#[test]
fn each_side_is_offset_by_its_own_width() {
// All on the right: 0 to the left, 10 to the right (side scale 2 × weight 10 / 2).
let p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!(b.y0.abs() < 1e-6 && (b.y1 - 10.0).abs() < 1e-6 && b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "{b:?}");
assert!((stroke_outline(&line(), &p, false).area().abs() - 1000.0).abs() < 1.0);
// Lopsided: 2 on the left, 8 on the right.
let p = props(vec![WidthPoint::from_sides(0.5, 0.4, 1.6)]);
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 8.0).abs() < 1e-6, "{b:?}");
// The right side swells in the middle while the left stays straight.
let p = props(vec![WidthPoint::new(0.0, 1.0), WidthPoint::from_sides(0.5, 1.0, 4.0), WidthPoint::new(1.0, 1.0)]);
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.y0 + 5.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn caps_wrap_the_middle_of_a_lopsided_end() {
// 0 on the left, 10 on the right: a round cap is a half circle of radius 5 around (100, 5).
let mut p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
p.cap_start = StrokeCap::Round;
p.cap_end = StrokeCap::Round;
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.x1 - 105.0).abs() < 0.05 && (b.x0 + 5.0).abs() < 0.05, "{b:?}");
assert!(b.y0 > -0.05 && (b.y1 - 10.0).abs() < 0.05, "{b:?}");
p.cap_end = StrokeCap::Square;
let b = stroke_outline(&line(), &p, false).bounding_box();
assert!((b.x1 - 105.0).abs() < 1e-6 && b.y0 > -1e-6, "{b:?}");
}
#[test]
fn dashes_and_closed_paths_keep_their_sides() {
let mut p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
p.dashes = vec![10.0, 10.0];
let s = stroke_outline(&line(), &p, false);
let b = s.bounding_box();
assert!(b.y0.abs() < 1e-6 && (b.y1 - 10.0).abs() < 1e-6, "{b:?}");
assert!((s.area().abs() - 500.0).abs() < 5.0, "{}", s.area());
// A closed square drawn clockwise on screen: its right side is the inside.
let mut sq = BezPath::new();
sq.move_to((0.0, 0.0));
sq.line_to((100.0, 0.0));
sq.line_to((100.0, 100.0));
sq.line_to((0.0, 100.0));
sq.close_path();
let p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]);
let b = stroke_outline(&sq, &p, true).bounding_box();
assert!(b.x0 > -1e-6 && b.y0 > -1e-6 && b.x1 < 100.0 + 1e-6 && b.y1 < 100.0 + 1e-6, "all inside: {b:?}");
}

View File

@ -2,9 +2,10 @@
//! follows a [`WidthProfile`] along each subpath.
//!
//! The path is flattened into polylines (sampled densely enough for the width to vary smoothly),
//! each point is offset along the polyline's normals by the interpolated half-width, the sides are
//! joined (round, miter or bevel on the outer side, through the centre on the inner side) and
//! capped. The result may overlap itself; fill it with the non-zero rule.
//! each point is offset along the polyline's normals by the interpolated half-width of that side
//! (the two sides can differ: one-sided widths), the sides are joined (round, miter or bevel on the
//! outer side, through the centre on the inner side) and capped. The result may overlap itself;
//! fill it with the non-zero rule.
use figcraft_doc::{StrokeCap, StrokeJoin, StrokeProps, WidthProfile};
use figcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2};
@ -132,13 +133,22 @@ pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> {
lines.iter().enumerate().filter_map(|(i, l)| l.nearest(p).map(|(t, d)| (i, t, d))).min_by(|a, b| a.2.total_cmp(&b.2))
}
/// Half-widths at a sample: `[along the normal, against it]`. The normal is the direction turned
/// clockwise on screen (y down), so the first is the right side looking along the path.
type Half = [f64; 2];
/// The half-width of `h` on side `sigma` (+1 = along the normal).
fn on(h: Half, sigma: f64) -> f64 {
if sigma > 0.0 { h[0] } else { h[1] }
}
/// One straight piece of an offset polyline: its ends, the half-widths there, its direction and
/// normal.
struct Seg {
a: Point,
b: Point,
ha: f64,
hb: f64,
ha: Half,
hb: Half,
d: Vec2,
n: Vec2,
}
@ -179,8 +189,9 @@ fn arc(out: &mut Vec<Point>, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64)
/// The corner between segments `p` and `c` on side `sigma` (+1 = the normal's side).
fn join(out: &mut Vec<Point>, p: &Seg, c: &Seg, sigma: f64, style: Style) {
let pe = p.b + p.n * (sigma * p.hb);
let cs = c.a + c.n * (sigma * c.ha);
let (phb, cha) = (on(p.hb, sigma), on(c.ha, sigma));
let pe = p.b + p.n * (sigma * phb);
let cs = c.a + c.n * (sigma * cha);
out.push(pe);
let (cross, dot) = (p.d.cross(c.d), p.d.dot(c.d));
if cross.abs() < 1e-12 && dot > 0.0 {
@ -193,12 +204,12 @@ fn join(out: &mut Vec<Point>, p: &Seg, c: &Seg, sigma: f64, style: Style) {
if outer {
let theta = if cross.abs() < 1e-12 { std::f64::consts::PI } else { cross.atan2(dot) };
match style.join {
StrokeJoin::Round => arc(out, v, pe - v, theta, p.hb, c.ha),
StrokeJoin::Round => arc(out, v, pe - v, theta, phb, cha),
StrokeJoin::Miter => {
let half = (theta / 2.0).cos();
let bis = p.n + c.n;
if half > 1e-6 && 1.0 / half <= style.miter_limit && bis.hypot() > 1e-9 {
out.push(v + bis * (sigma * (p.hb + c.ha) / 2.0 / half / bis.hypot()));
out.push(v + bis * (sigma * (phb + cha) / 2.0 / half / bis.hypot()));
}
}
StrokeJoin::Bevel => {}
@ -217,25 +228,26 @@ fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec<Point> {
let prev = if j == 0 { closed.then(|| segs.last()).flatten() } else { segs.get(j - 1) };
match prev {
Some(p) => join(&mut out, p, s, sigma, style),
None => out.push(s.a + s.n * (sigma * s.ha)),
None => out.push(s.a + s.n * (sigma * on(s.ha, sigma))),
}
}
if !closed && let Some(l) = segs.last() {
out.push(l.b + l.n * (sigma * l.hb));
out.push(l.b + l.n * (sigma * on(l.hb, sigma)));
}
out
}
/// A cap at `c` facing `dir` (unit, outwards) with half-width `h`: from the `dir.turn_90()` side
/// to the other one.
fn cap(out: &mut Vec<Point>, c: Point, dir: Vec2, h: f64, kind: StrokeCap) {
/// A cap at `c` facing `dir` (unit, outwards) from the `dir.turn_90()` side, `from` wide, to the
/// other side, `to` wide. Lopsided ends are capped around the middle of the end.
fn cap(out: &mut Vec<Point>, c: Point, dir: Vec2, from: f64, to: f64, kind: StrokeCap) {
let n = dir.turn_90();
let (m, r) = (c + n * ((from - to) / 2.0), (from + to) / 2.0);
match cap_shape(kind) {
StrokeCap::Square => {
out.push(c + n * h + dir * h);
out.push(c - n * h + dir * h);
out.push(m + n * r + dir * r);
out.push(m - n * r + dir * r);
}
StrokeCap::Round => arc(out, c, n, -std::f64::consts::PI, h, h),
StrokeCap::Round => arc(out, m, n, -std::f64::consts::PI, r, r),
_ => {}
}
}
@ -251,7 +263,7 @@ fn push_contour(path: &mut BezPath, pts: impl IntoIterator<Item = Point>) {
}
/// Outline one piece (a subpath or a dash) with its half-widths.
fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, style: Style) {
fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, style: Style) {
let segs: Vec<Seg> = pts
.windows(2)
.zip(hw.windows(2))
@ -266,11 +278,12 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, st
.collect();
let (Some(first), Some(last)) = (segs.first(), segs.last()) else {
// A zero-length piece is a dot with round caps.
if let (Some(p), Some(h)) = (pts.first(), hw.first())
let r = hw.first().map_or(0.0, |h| (h[0] + h[1]) / 2.0);
if let Some(p) = pts.first()
&& cap_shape(style.cap_start) == StrokeCap::Round
&& *h > 0.0
&& r > 0.0
{
path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, *h), FLATTEN_TOL));
path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, r), FLATTEN_TOL));
}
return;
};
@ -282,18 +295,18 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, st
return;
}
let mut ring = left;
cap(&mut ring, last.b, last.d, last.hb, style.cap_end);
cap(&mut ring, last.b, last.d, last.hb[0], last.hb[1], style.cap_end);
ring.extend(right.into_iter().rev());
cap(&mut ring, first.a, -first.d, first.ha, style.cap_start);
cap(&mut ring, first.a, -first.d, first.ha[1], first.ha[0], style.cap_start);
push_contour(path, ring);
}
/// Split a sampled polyline into dashes (`pattern` alternates dash and gap lengths).
fn dashes(pts: &[Point], hw: &[f64], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<f64>)> {
fn dashes(pts: &[Point], hw: &[Half], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<Half>)> {
let mut out = Vec::new();
let Some(&first) = pattern.first() else { return out };
let (mut idx, mut left, mut on) = (0usize, first, true);
let mut cur: (Vec<Point>, Vec<f64>) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect());
let mut cur: (Vec<Point>, Vec<Half>) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect());
for (p, h) in pts.windows(2).zip(hw.windows(2)) {
let (a, b, ha, hb) = (p[0], p[1], h[0], h[1]);
let len = (b - a).hypot();
@ -301,7 +314,7 @@ fn dashes(pts: &[Point], hw: &[f64], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<f6
while len - used > left {
used += left;
let f = if len > 0.0 { used / len } else { 0.0 };
let (q, hq) = (a.lerp(b, f), ha + (hb - ha) * f);
let (q, hq) = (a.lerp(b, f), [ha[0] + (hb[0] - ha[0]) * f, ha[1] + (hb[1] - ha[1]) * f]);
if on {
cur.0.push(q);
cur.1.push(hq);
@ -381,7 +394,13 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
continue;
}
let (pts, lens) = resample(line, len / SAMPLES_PER_SUBPATH, &mut budget)?;
let hw: Vec<f64> = lens.iter().map(|s| 0.5 * width * profile.scale_at(s / len)).collect();
let hw: Vec<Half> = lens
.iter()
.map(|s| {
let (left, right) = profile.sides_at(s / len);
[0.5 * width * right, 0.5 * width * left]
})
.collect();
match &pattern {
Some(pat) if len / pat.iter().sum::<f64>() * (pat.len() as f64) < MAX_DASHES as f64 => {
for (p, h) in dashes(&pts, &hw, pat) {

View File

@ -0,0 +1,121 @@
//! Boolean group results for drawing, cached so editing one operand stays cheap.
//!
//! A boolean group's result is its operands folded bottom to top, each combined with the result
//! of those below it by its own operation (`Node::bool_op`, else the group's). Every prefix of the
//! fold is cached under a rolling hash of the operands it covers, so when an operand changes (a
//! drag, a point edit) the fold restarts from the longest unchanged prefix: the operands below the
//! edited one are never recombined, and groups whose operands didn't change aren't recomputed at
//! all. The caches drop their least recently used half when full, so a long drag doesn't evict
//! every other group's result.
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_doc::BoolOp;
use figcraft_geom::BezPath;
use crate::draw::hash_path;
/// A bounded map of computed paths; the least recently used half goes when it is full.
pub(crate) struct PathCache {
map: HashMap<u64, (Arc<BezPath>, u64)>,
tick: u64,
cap: usize,
}
impl PathCache {
pub(crate) fn new(cap: usize) -> Self {
Self { map: HashMap::new(), tick: 0, cap: cap.max(2) }
}
pub(crate) fn get(&mut self, key: u64) -> Option<Arc<BezPath>> {
self.tick += 1;
let tick = self.tick;
self.map.get_mut(&key).map(|(p, used)| {
*used = tick;
p.clone()
})
}
pub(crate) fn insert(&mut self, key: u64, path: Arc<BezPath>) {
if self.map.len() >= self.cap {
let mut ticks: Vec<u64> = self.map.values().map(|(_, t)| *t).collect();
// Keep the newer half: drop every entry used at or before the median tick.
let mid = (ticks.len() / 2).saturating_sub(1);
let (_, cut, _) = ticks.select_nth_unstable(mid);
let cut = *cut;
self.map.retain(|_, (_, t)| *t > cut);
}
self.tick += 1;
self.map.insert(key, (path, self.tick));
}
}
/// The shared cache of a cache-returning `OnceLock`, recovering from a poisoned lock.
pub(crate) fn locked(c: &Mutex<PathCache>) -> std::sync::MutexGuard<'_, PathCache> {
c.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn cache() -> &'static Mutex<PathCache> {
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PathCache::new(4096)))
}
thread_local! {
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// How many boolean steps (one operand combined with the result below it) this thread has
/// computed: what a cache miss costs. For tests and benchmarks.
pub fn boolean_steps() -> u64 {
STEPS.with(std::cell::Cell::get)
}
/// The result of folding `operands` (bottom to top, each with its operation; the bottom one's is
/// unused), reusing the longest cached prefix. Invalid geometry gives an empty path.
pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> BezPath {
if operands.len() < 2 {
return figcraft_pathops::boolean_ops(operands.iter().map(|(p, op)| (p, *op))).unwrap_or_default();
}
// keys[i]: the fold of operands 0..=i.
let mut h = DefaultHasher::new();
let keys: Vec<u64> = operands
.iter()
.enumerate()
.map(|(i, (p, op))| {
if i > 0 {
(*op as u8).hash(&mut h);
}
hash_path(p, &mut h);
9u8.hash(&mut h);
h.finish()
})
.collect();
let hit = {
let mut c = locked(cache());
keys.iter().enumerate().skip(1).rev().find_map(|(i, k)| c.get(*k).map(|p| (i, p)))
};
let (start, mut acc) = match hit {
Some((i, p)) if i + 1 == operands.len() => return (*p).clone(),
Some((i, p)) => (i, (*p).clone()),
None => match operands.first() {
Some((p, _)) => (0, p.clone()),
None => return BezPath::new(),
},
};
let mut fresh = Vec::new();
for ((p, op), key) in operands.iter().zip(&keys).skip(start + 1) {
STEPS.with(|s| s.set(s.get() + 1));
acc = match figcraft_pathops::boolean_step(&acc, p, *op) {
Ok(r) => r,
Err(_) => return BezPath::new(),
};
fresh.push((*key, Arc::new(acc.clone())));
}
let mut c = locked(cache());
for (k, p) in fresh {
c.insert(k, p);
}
acc
}

View File

@ -0,0 +1,135 @@
//! Live boolean editing in the renderer: per-operand operations, how much of a group is
//! recomputed when one operand changes, the bounded path caches, and one-sided widths on screen.
use super::*;
use crate::boolean::{PathCache, boolean_steps, fold_cached};
use figcraft_color::{Color, Paint};
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile};
use figcraft_geom::kurbo::{BezPath, Shape};
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
let i = ((y * p.width() + x) * 4) as usize;
p.data().get(i).is_some_and(|r| *r < 40)
}
fn render(d: &Document, p: NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap()
}
/// A boolean group doing `op` over squares 20 wide at x = 10, 25, 40, … (overlapping neighbours),
/// on a page of its own.
fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec<NodeId>) {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let b = d.create(p, NodeKind::BooleanOp { op }, "B", 0.0, 0.0, 0.0, 0.0).unwrap();
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let kids = (0..n).map(|i| d.create(b, NodeKind::Rectangle, "R", 10.0 + 15.0 * i as f64, 10.0, 20.0, 20.0).unwrap()).collect();
d.fit_to_children(b).unwrap();
(d, p, b, kids)
}
fn outline(d: &Document, id: NodeId) -> BezPath {
node_outline(d, d.node(id).unwrap()).unwrap().0
}
#[test]
fn operands_combine_by_their_own_operations() {
// Union of three squares in a row, the middle one subtracted instead.
let (mut d, p, b, kids) = row_of_squares(BoolOp::Union, 3);
let union = outline(&d, b).area().abs();
assert!((union - 20.0 * 50.0).abs() < 0.5, "{union}");
d.get_mut(kids[1]).unwrap().bool_op = Some(BoolOp::Subtract);
let mixed = outline(&d, b);
// (A − B) ∪ C: A keeps x 10..25, C is whole (x 40..60).
assert!((mixed.area().abs() - 20.0 * (15.0 + 20.0)).abs() < 0.5, "{}", mixed.area());
let pix = render(&d, p);
assert!(dark(&pix, 15, 20) && !dark(&pix, 32, 20) && dark(&pix, 50, 20));
// The bottom operand's own operation changes nothing.
d.get_mut(kids[0]).unwrap().bool_op = Some(BoolOp::Intersect);
assert!((outline(&d, b).area().abs() - mixed.area().abs()).abs() < 1e-9);
}
#[test]
fn editing_one_operand_recombines_only_from_it_up() {
// Distinct sizes so no other test's cached results can be reused.
let (mut d, _, b, kids) = row_of_squares(BoolOp::Union, 8);
for (i, k) in kids.iter().enumerate() {
d.get_mut(*k).unwrap().height = 20.0 + i as f64 * 0.37;
}
let steps = |d: &Document| {
let before = boolean_steps();
let _ = outline(d, b);
boolean_steps() - before
};
assert_eq!(steps(&d), 7, "the first time: every operand above the bottom one");
assert_eq!(steps(&d), 0, "unchanged: from the cache");
// Moving the sixth operand (a drag frame) recombines it and the two above it.
let mut moved = d.clone();
moved.get_mut(kids[5]).unwrap().transform = figcraft_geom::Affine::translate((3.0, 1.5)) * d.node(kids[5]).unwrap().transform;
assert_eq!(steps(&moved), 3);
// The top one: one step. Going back: cached.
let mut top = moved.clone();
top.get_mut(kids[7]).unwrap().height = 33.3;
assert_eq!(steps(&top), 1);
assert_eq!(steps(&moved), 0);
assert_eq!(steps(&d), 0);
// Hiding an operand drops it from the fold (those above it are recombined).
let mut hidden = d.clone();
hidden.get_mut(kids[6]).unwrap().visible = false;
assert_eq!(steps(&hidden), 1);
}
#[test]
fn other_groups_are_not_recomputed() {
let (mut d, _, b, kids) = row_of_squares(BoolOp::Exclude, 4);
let (d2, _, b2, _) = row_of_squares(BoolOp::Intersect, 5);
for (i, k) in kids.iter().enumerate() {
d.get_mut(*k).unwrap().width = 21.0 + i as f64 * 0.29;
}
let _ = outline(&d, b);
let _ = outline(&d2, b2);
let before = boolean_steps();
d.get_mut(kids[3]).unwrap().width = 30.1;
let _ = outline(&d, b);
let _ = outline(&d2, b2);
assert_eq!(boolean_steps() - before, 1, "only the edited group's top step");
}
#[test]
fn caches_drop_the_least_recently_used_half() {
let mut c = PathCache::new(4);
let p = std::sync::Arc::new(BezPath::new());
for k in 0..4 {
c.insert(k, p.clone());
}
// Using 0 and 1 keeps them when the cache fills up.
assert!(c.get(0).is_some() && c.get(1).is_some());
c.insert(9, p.clone());
assert!(c.get(0).is_some() && c.get(1).is_some() && c.get(9).is_some());
assert!(c.get(2).is_none() && c.get(3).is_none());
// Junk and degenerate folds.
assert!(fold_cached(&[]).elements().is_empty());
let mut bad = BezPath::new();
bad.move_to((f64::NAN, 0.0));
bad.line_to((1.0, 1.0));
let sq = figcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1);
assert!(fold_cached(&[(sq, BoolOp::Union), (bad, BoolOp::Union)]).elements().is_empty());
}
#[test]
fn one_sided_widths_paint_one_side() {
// A line from (10, 100) to (190, 100), 20 wide, all of it on the right (+y, looking along it).
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let l = d.create(p, NodeKind::Line, "L", 10.0, 100.0, 180.0, 0.0).unwrap();
let n = d.get_mut(l).unwrap();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 20.0;
n.stroke.width_profile = Some(WidthProfile { points: vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)] });
let pix = render(&d, p);
assert!(dark(&pix, 100, 110) && dark(&pix, 100, 118), "the right side, 20 deep");
assert!(!dark(&pix, 100, 95), "nothing on the left");
// The paint bounds reach the widest side.
let b = d.world_render_bounds(l).unwrap();
assert!(b.y1 >= 120.0 - 1e-6, "{b:?}");
}

View File

@ -10,6 +10,7 @@ use figcraft_geom::{Affine, BezPath, Rect, Shape};
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use crate::backdrop::{self, Under};
use crate::boolean::{PathCache, locked};
use crate::paint::{prepare, ts_blend};
use crate::{blur, grain, lod, shadow};
@ -68,9 +69,9 @@ fn fill_with(
}
/// Stroke outline cache: (geometry hash) → outline path.
fn stroke_cache() -> &'static Mutex<HashMap<u64, Arc<BezPath>>> {
static C: OnceLock<Mutex<HashMap<u64, Arc<BezPath>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
fn stroke_cache() -> &'static Mutex<PathCache> {
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PathCache::new(4096)))
}
pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
@ -143,21 +144,32 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
}
props.dashes.len().hash(&mut h);
for p in props.profile().map(|p| p.points.as_slice()).unwrap_or_default() {
(p.t.to_bits(), p.scale.to_bits()).hash(&mut h);
(p.t.to_bits(), p.scale.to_bits(), p.bias.to_bits()).hash(&mut h);
}
let key = h.finish();
if let Some(p) = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
if let Some(p) = locked(stroke_cache()).get(key) {
return p;
}
let out = Arc::new(figcraft_pathops::stroke_outline(path, props, closed));
let mut c = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 4096 {
c.clear();
}
c.insert(key, out.clone());
locked(stroke_cache()).insert(key, out.clone());
out
}
/// A node's painted stroke area in local coordinates with the rule to fill it by (`None` without a
/// visible stroke or geometry). Text strokes follow its glyphs and aren't covered.
pub fn stroke_fill(doc: &Document, n: &Node) -> Option<(Arc<BezPath>, FillRule)> {
if !n.has_visible_stroke() {
return None;
}
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
let geom = match &n.kind {
NodeKind::BooleanOp { .. } => node_outline(doc, n).map(|(p, _)| p),
NodeKind::Text(_) | NodeKind::Group | NodeKind::Page { .. } => None,
_ => figcraft_doc::geometry::stroke_geometry(n),
}?;
Some((stroke_area(n, &geom, closed), stroke_rule(n, closed)))
}
/// Even-odd ring paths need the even-odd rule; everything else non-zero.
pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) {
@ -172,7 +184,7 @@ pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
match &n.kind {
NodeKind::BooleanOp { op } => {
let paths: Vec<BezPath> = n
let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n
.children
.iter()
.filter_map(|c| doc.node(*c))
@ -180,11 +192,10 @@ pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
.filter_map(|c| {
let (mut p, _) = subtree_outline(doc, c, 0)?;
p.apply_affine(c.transform);
Some(p)
Some((p, c.bool_op.unwrap_or(*op)))
})
.collect();
let p = boolean_cached(&paths, *op);
Some((p, FillRule::EvenOdd))
Some((crate::boolean::fold_cached(&operands), FillRule::EvenOdd))
}
NodeKind::Text(_) => {
let (l, dy) = figcraft_text::layout_node(n)?;
@ -230,31 +241,6 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat
}
}
fn boolean_cache() -> &'static Mutex<HashMap<u64, Arc<BezPath>>> {
static C: OnceLock<Mutex<HashMap<u64, Arc<BezPath>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn boolean_cached(paths: &[BezPath], op: figcraft_doc::BoolOp) -> BezPath {
let mut h = std::collections::hash_map::DefaultHasher::new();
(op as u8).hash(&mut h);
for p in paths {
hash_path(p, &mut h);
9u8.hash(&mut h);
}
let key = h.finish();
if let Some(p) = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return (**p).clone();
}
let out = figcraft_pathops::boolean(paths, op).unwrap_or_default();
let mut c = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 2048 {
c.clear();
}
c.insert(key, Arc::new(out.clone()));
out
}
fn device_rect(xf: Affine, r: Rect) -> Rect {
figcraft_geom::transform_rect_bbox(xf, r)
}

View File

@ -7,6 +7,7 @@
pub mod backdrop;
pub mod blur;
pub mod boolean;
pub mod draw;
pub mod fx;
pub mod glass;
@ -17,7 +18,7 @@ pub mod paint;
pub mod retained;
pub mod shadow;
pub use draw::{node_outline, paint_bounds, subtree_outline, ts_path};
pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path};
pub use retained::{Retained, Work};
pub use tiny_skia::{FillRule, Pixmap};
@ -376,6 +377,8 @@ mod backdrop_tests;
#[cfg(test)]
mod band_tests;
#[cfg(test)]
mod boolean_cache_tests;
#[cfg(test)]
mod effect_order_tests;
#[cfg(test)]
mod grain_tests;

View File

@ -784,6 +784,14 @@ pub fn toggle(resp: &Response, tip: &str, on: bool) {
});
}
/// A status shown in place (a badge), with what it means.
pub fn status(resp: &Response, label: &str, description: &str) {
node(resp, label, |n| {
n.set_role(Role::Status);
n.set_description(description);
});
}
/// A tab, selected when it's the one shown.
pub fn tab(resp: &Response, label: &str, selected: bool) {
node(resp, label, |n| {

View File

@ -709,6 +709,23 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option<Vec<Vec<Pos
Some(out)
}
/// A layer's outline on the canvas: its shape for ellipses, vectors, polygons, stars and boolean
/// groups, its box otherwise.
fn draw_outline(app: &FigApp, painter: &egui::Painter, doc: &Document, id: NodeId, s: Stroke) {
match outline_shape(app, doc, id) {
Some(lines) => {
for l in lines {
painter.add(Shape::line(l, s));
}
}
None => {
if let Some(q) = node_quad(app, doc, id) {
quad(painter, q, s);
}
}
}
}
/// Everything drawn over the rendered page; returns the frame labels drawn (frame, corners on the
/// page) for the engine to hit-test.
fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [Point; 4])> {
@ -780,19 +797,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
&& app.session.prefs.highlight_on_hover
&& app.session.tool.overlay.measure_box.is_none()
{
let col = outline_colour(&doc, h);
match outline_shape(app, &doc, h) {
Some(lines) => {
for l in lines {
painter.add(Shape::line(l, stroke(col, 1.5)));
}
}
None => {
if let Some(q) = node_quad(app, &doc, h) {
quad(painter, q, stroke(col, 1.5));
}
}
}
draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5));
}
// Inside a boolean group (an operand selected or in vector edit mode): every operand's outline,
// so the shapes making the live result stay visible while one of them changes.
let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id));
for id in operands.iter().filter(|o| !sel.contains(o)) {
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0));
}
// Drop target / insertion indicator.
let ov = &app.session.tool.overlay;
@ -835,18 +846,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() {
let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT };
for id in &sel {
match outline_shape(app, &doc, *id) {
Some(lines) => {
for l in lines {
painter.add(Shape::line(l, stroke(col, 1.0)));
}
}
None => {
if let Some(q) = node_quad(app, &doc, *id) {
quad(painter, q, stroke(col, 1.0));
}
}
}
draw_outline(app, painter, &doc, *id, stroke(col, 1.0));
}
// The shape builder reshapes by regions: no transform box.
if app.session.tool.tool != ToolId::ShapeBuilder
@ -1014,6 +1014,7 @@ fn arc_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) {
/// Variable width tool: each width point on the path with a bar across the stroke to its two
/// width handles (the selected point filled), and a faint preview where a drag would add one.
fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &figcraft_engine::vectoredit::VectorEdit, zoom: f64) {
use figcraft_doc::WidthSide;
use figcraft_engine::vectoredit::{WHit, width_handles, width_preview};
let hover = app.session.tool.overlay.width_hover;
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
@ -1022,8 +1023,8 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT };
painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0));
painter.line_segment([a, b], stroke(col, 1.0));
let grab = !ghost && hover == Some(WHit::Handle { index: h.index });
for e in [a, b] {
for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] {
let grab = !ghost && hover == Some(WHit::Handle { index: h.index, side });
let r = if grab { 4.5 } else { 3.5 };
painter.circle_filled(e, r, Color32::WHITE);
painter.circle_stroke(e, r, stroke(col, 1.0));
@ -1037,7 +1038,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
for h in width_handles(doc, ve, zoom) {
draw(&h, false);
}
if let Some(WHit::Path { t }) = hover {
if let Some(WHit::Path { t, .. }) = hover {
for h in width_preview(doc, ve, t, zoom) {
draw(&h, true);
}

View File

@ -209,6 +209,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
color_profile_dialog(app, ctx);
crate::plugins_ui::show(app, ctx);
crate::live::join_dialog(app, ctx);
crate::live::publish_dialog(app, ctx);
}
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the

View File

@ -166,6 +166,12 @@ icons! {
Reaction,
/// Drag to reorder: two columns of three dots.
DragHandle,
/// Arc start: a circle with a radius to the dot where the arc begins.
ArcStart,
/// Arc sweep: a circle with a wedge cut out of it.
ArcSweep,
/// Arc ratio: a ring.
ArcRatio,
}
/// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise).
@ -712,7 +718,8 @@ fn draw(p: &P, icon: Icon) {
}
Play => p.fill(&[(4.5, 2.5), (13.5, 8.0), (4.5, 13.5)]),
Home => {
p.poly(&[(3.5, 13.5), (3.5, 7.0), (8.0, 2.8), (12.5, 7.0), (12.5, 13.5)], true);
// Unhinted so the roof's peak stays centred; the floor stops at the door.
p.path(&[(6.5, 13.5), (3.5, 13.5), (3.5, 7.0), (8.0, 2.8), (12.5, 7.0), (12.5, 13.5), (9.5, 13.5)], false);
p.poly(&[(6.5, 13.5), (6.5, 9.5), (9.5, 9.5), (9.5, 13.5)], false);
}
Bell => {
@ -819,9 +826,10 @@ fn draw(p: &P, icon: Icon) {
StrokeIcon => p.rect(2.5, 2.5, 13.5, 13.5, 2.0),
Fill => p.fill_rect(2.0, 2.0, 14.0, 14.0, 2.0),
Moon => {
// A crescent: the outer rim, then the shadowing circle's edge back.
let mut m: Vec<(f32, f32)> = arc_pts(8.0, 8.0, 5.5, 0.166, TAU - 1.737).collect();
m.extend(arc_pts(11.0, 5.0, 4.6, -2.586, 1.016 - TAU));
// A crescent: the outer rim, then the shadowing circle's edge back. Both arcs stop short
// of where the circles meet, so each tip is two blunt joins (a single cusp join folds).
let mut m: Vec<(f32, f32)> = arc_pts(8.0, 8.0, 5.5, 0.166 + 0.12, TAU - 1.737 - 0.12).collect();
m.extend(arc_pts(11.0, 5.0, 4.6, -2.586 - 0.18, 1.016 - TAU + 0.18));
p.path(&m, true);
}
Undo | Redo => {
@ -968,6 +976,20 @@ fn draw(p: &P, icon: Icon) {
p.rect(x, y, x + 4.0, y + 4.0, 1.0);
}
}
ArcStart => {
p.circle(8.0, 8.0, 5.5);
p.line(8.0, 8.0, 13.5, 8.0);
p.dot(13.5, 8.0, 1.6);
}
ArcSweep => {
let mut wedge = vec![(8.0, 8.0)];
wedge.extend(arc_pts(8.0, 8.0, 5.5, 0.0, 1.5 * PI));
p.path(&wedge, true);
}
ArcRatio => {
p.circle(8.0, 8.0, 5.5);
p.circle(8.0, 8.0, 2.5);
}
Logo => {
// FigCraft's own mark: three stacked rounded tiles forming an "F" shape.
p.fill_rect(3.0, 2.0, 13.0, 5.0, 1.0);

View File

@ -14,8 +14,22 @@ use crate::{FigApp, LeftTab};
pub const ROW: f32 = 32.0;
/// A boolean operation's icon.
pub fn op_icon(op: figcraft_doc::BoolOp) -> Icon {
match op {
figcraft_doc::BoolOp::Union => Icon::Union,
figcraft_doc::BoolOp::Subtract => Icon::Subtract,
figcraft_doc::BoolOp::Intersect => Icon::Intersect,
figcraft_doc::BoolOp::Exclude => Icon::Exclude,
}
}
pub fn node_icon(doc: &Document, id: NodeId) -> Icon {
let Some(n) = doc.node(id) else { return Icon::Rectangle };
// An operand combining by an operation other than its group's shows that operation.
if let Some(op) = figcraft_engine::booleans::distinct_op(doc, id) {
return op_icon(op);
}
match &n.kind {
NodeKind::Page { .. } => Icon::Page,
NodeKind::Frame if figcraft_engine::components::is_slot(doc, id) => Icon::Slot,
@ -51,12 +65,7 @@ pub fn node_icon(doc: &Document, id: NodeId) -> Icon {
}
NodeKind::Vector { .. } => Icon::Vector,
NodeKind::Text(_) => Icon::Text,
NodeKind::BooleanOp { op } => match op {
figcraft_doc::BoolOp::Union => Icon::Union,
figcraft_doc::BoolOp::Subtract => Icon::Subtract,
figcraft_doc::BoolOp::Intersect => Icon::Intersect,
figcraft_doc::BoolOp::Exclude => Icon::Exclude,
},
NodeKind::BooleanOp { op } => op_icon(*op),
NodeKind::Component { .. } => Icon::Component,
NodeKind::ComponentSet { .. } => Icon::ComponentSet,
NodeKind::Instance { .. } => Icon::Instance,

View File

@ -76,6 +76,8 @@ mod layers_rename_tests;
#[cfg(test)]
mod live_join_tests;
#[cfg(test)]
mod live_server_tests;
#[cfg(test)]
mod live_tests;
#[cfg(test)]
mod live_text_tests;
@ -99,6 +101,8 @@ mod proto_responsive_tests;
mod shortcuts_tests;
#[cfg(test)]
mod titlebar_tests;
#[cfg(test)]
mod vector_tail_ui_tests;
use std::collections::BTreeSet;
use std::path::PathBuf;
@ -748,7 +752,7 @@ impl FigApp {
present::show_pane(self, &ctx);
comments::keys(self, &ctx);
dev_compare::keys(self, &ctx);
live::join_keys(self, &ctx);
live::dialog_keys(self, &ctx);
shortcuts::handle(self, &ctx, typing);
menus::popups(self, &ctx);
variable_ui::picker(self, &ctx);

View File

@ -1,14 +1,15 @@
//! Live sessions in the window: other people's cursors (smoothly following their pointer, with a
//! name tag) and selections on the canvas (their edits arrive through the engine's sync), their avatars at the top of the right panel (click one
//! to follow their view), the Share popover (start a session, the invite link, stop) and the
//! Join dialog. Everything acts through the `collab.*` commands; the state is the engine's
//! to follow their view), the Share popover (start a session, the invite link, stop; the server,
//! file and access of a session you joined; a published file's links), the Join dialog and the
//! Publish to server dialog. Everything acts through the `collab.*` commands; the state is the engine's
//! (`Session::live`). The network wakes the UI when something arrives, and cursors only ask for
//! frames while they glide, so an idle session draws nothing.
use std::collections::BTreeMap;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
use figcraft_engine::live::{PEOPLE, Role, Status, initials, parse_color, presence_page};
use figcraft_engine::live::{Access, PEOPLE, Role, Status, initials, parse_color, presence_page};
use serde_json::json;
use crate::FigApp;
@ -19,12 +20,17 @@ use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets;
/// In the browser: what joining needs (a page can't host, and https pages can't reach `ws://`).
const BROWSER_NOTE: &str =
"In the browser you can join sessions hosted by the desktop app. A page opened over https can only reach sessions on this computer.";
const BROWSER_NOTE: &str = "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS).";
/// How undo works in a session (per person).
const UNDO_NOTE: &str = "Undo takes back only your own changes; what others changed since stays.";
/// What a view link means for you.
const VIEW_NOTE: &str = "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit.";
/// What a published file's links are.
const PUBLISHED_NOTE: &str = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them.";
/// How fast a remote cursor closes in on where it was last reported (per second).
const GLIDE: f64 = 20.0;
/// Most of one person's selection outlined.
@ -45,6 +51,21 @@ pub struct LiveUi {
focus_join: bool,
/// Start sessions on the local network (else this machine only).
pub lan: bool,
/// The Publish to server dialog's fields (open when `Some`).
pub publish: Option<PublishForm>,
/// Put the keyboard in the Publish dialog's first field on its next frame.
focus_publish: bool,
/// The last server address published to (kept for this run only: it carries the server key).
last_server: String,
}
/// What the Publish to server dialog asks for.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PublishForm {
/// The server's address with its key (`ws://host:47900/?key=…`).
pub server: String,
/// The file's name on the server.
pub name: String,
}
impl LiveUi {
@ -260,34 +281,37 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
menu_popup(anchor).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let t = Tokens::get(ui.ctx());
ui.spacing_mut().item_spacing.y = 6.0;
ui.add_space(4.0);
ui.label(egui::RichText::new("Live session").font(semibold(12.0)));
let status = app.session.live.status.clone();
let role = app.session.live.role();
let (line, col) = match (&status, role) {
(Status::Live, Some(Role::Host)) => (format!("Live · {}", people_count(app.session.live.peers.len())), t.success),
(Status::Live, _) => {
let file = app.session.live.info.as_ref().map(|i| i.file_name.clone()).unwrap_or_default();
(if file.is_empty() { "Joined".to_string() } else { format!("Joined {file}") }, t.success)
}
(Status::Failed { .. }, _) => (status.label(), t.danger),
_ => (status.label(), t.text_secondary),
};
widgets::label(ui, &line, col);
match (&status, role) {
(Status::Off | Status::Failed { .. }, _) => start_ui(app, ui, &t),
(Status::Live, Some(Role::Host)) => host_ui(app, ui),
_ => {
if let Some(i) = app.session.live.info.as_ref().filter(|i| !i.pages.is_empty()) {
let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect();
widgets::label(ui, &format!("Pages: {}", pages.join(", ")), t.text_secondary);
}
widgets::label(ui, UNDO_NOTE, t.text_secondary);
if widgets::button(ui, "Leave session", false, true).clicked() {
app.run("collab.leave", json!({}));
ui.set_max_width(300.0);
// Menus lay out justified and don't wrap: the session's notes wrap, left-aligned, within
// the popover's width.
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
ui.add_space(4.0);
ui.label(egui::RichText::new("Live session").font(semibold(12.0)));
let status = app.session.live.status.clone();
let role = app.session.live.role();
let others = app.session.live.peers.len();
let published = app.session.live.published.is_some();
// Connecting with a tab already chosen and nobody's welcome yet: the upload.
let publishing = status == Status::Connecting && role.is_none() && app.session.live.doc_uid.is_some();
let (line, col) = match (&status, role) {
(Status::Live, Some(Role::Host)) => (format!("Live · {}", people_count(others)), t.success),
(Status::Live, _) if published => (format!("Published · {}", people_count(others)), t.success),
(Status::Connecting, _) if publishing => ("Publishing…".to_string(), t.text_secondary),
(Status::Live, _) => {
let file = app.session.live.info.as_ref().map(|i| i.file_name.clone()).unwrap_or_default();
(if file.is_empty() { "Joined".to_string() } else { format!("Joined {file}") }, t.success)
}
(Status::Failed { .. }, _) => (status.label(), t.danger),
_ => (status.label(), t.text_secondary),
};
widgets::label(ui, &line, col);
match (&status, role) {
(Status::Off | Status::Failed { .. }, _) => start_ui(app, ui, &t),
(Status::Live, Some(Role::Host)) => host_ui(app, ui),
_ => guest_ui(app, ui, &t),
}
}
});
ui.separator();
if menu_item(ui, "Export…", "") {
app.ui.export_preview = true;
@ -340,12 +364,51 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
ui.close();
}
});
ui.separator();
widgets::label(ui, "Or keep the file on a FigCraft server and share links to edit or to view it.", t.text_secondary);
if widgets::button(ui, "Publish to server…", false, true).clicked() {
open_publish(app);
ui.close();
}
}
/// Hosting: the invite link and URL to copy, and stop.
fn host_ui(app: &mut FigApp, ui: &mut Ui) {
let Some(info) = app.session.live.info.clone() else { return };
for (label, value, what) in [("Invite link", &info.link, "Invite link copied"), ("WebSocket URL", &info.url, "WebSocket URL copied")] {
link_rows(app, ui, &[("Invite link", &info.link, "Invite link copied"), ("WebSocket URL", &info.url, "WebSocket URL copied")]);
let t = Tokens::get(ui.ctx());
widgets::label(ui, "The link carries the session's secret: share it only with people you invite.", t.text_secondary);
widgets::label(ui, UNDO_NOTE, t.text_secondary);
if widgets::button(ui, "Stop session", false, true).clicked() {
app.run("collab.stop", json!({}));
}
}
/// Joined, or publishing: where the file is and what you may do there, a published file's links,
/// and leave (the file stays open as your copy).
fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
let live = &app.session.live;
let view = live.info.as_ref().is_some_and(|i| i.access == Access::View);
if let Some(i) = live.info.as_ref() {
widgets::label(ui, &format!("{} · {}", i.address, if view { "View only" } else { "Can edit" }), t.text_secondary);
if !i.pages.is_empty() {
let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect();
widgets::label(ui, &format!("Pages: {}", pages.join(", ")), t.text_secondary);
}
}
if let Some(p) = live.published.clone() {
link_rows(app, ui, &[("Edit link", &p.edit, "Edit link copied"), ("View link", &p.view, "View link copied")]);
widgets::label(ui, PUBLISHED_NOTE, t.text_secondary);
}
widgets::label(ui, if view { VIEW_NOTE } else { UNDO_NOTE }, t.text_secondary);
if widgets::button(ui, "Leave session", false, true).on_hover_text("Your copy of the file stays open").clicked() {
app.run("collab.leave", json!({}));
}
}
/// Links to copy: a label, the link (elided, monospace) and a Copy button each.
fn link_rows(app: &mut FigApp, ui: &mut Ui, rows: &[(&str, &String, &str)]) {
for (label, value, what) in rows {
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
let w = ui.available_width() - 64.0;
@ -356,16 +419,10 @@ fn host_ui(app: &mut FigApp, ui: &mut Ui) {
});
if widgets::button(ui, "Copy", false, true).on_hover_text(format!("Copy the {}", label.to_lowercase())).clicked() {
app.services.clipboard_set_text(value);
app.toast(what);
app.toast(*what);
}
});
}
let t = Tokens::get(ui.ctx());
widgets::label(ui, "The link carries the session's secret: share it only with people you invite.", t.text_secondary);
widgets::label(ui, UNDO_NOTE, t.text_secondary);
if widgets::button(ui, "Stop session", false, true).clicked() {
app.run("collab.stop", json!({}));
}
}
/// Open the Join dialog, with `link` in its field (focused).
@ -382,17 +439,20 @@ pub fn open_invite(app: &mut FigApp, link: &str) {
}
}
/// Escape closes the Join dialog (before the canvas's shortcuts see it: it doesn't deselect).
pub fn join_keys(app: &mut FigApp, ctx: &egui::Context) {
if app.ui.live.join.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
app.ui.live.join = None;
/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't
/// deselect).
pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) {
let live = &mut app.ui.live;
if (live.join.is_some() || live.publish.is_some()) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
live.join = None;
live.publish = None;
}
}
/// File ▸ Join live session: paste an invite link.
pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
// The editor takes Escape before its shortcuts; the home screen has none.
join_keys(app, ctx);
dialog_keys(app, ctx);
let Some(mut link) = app.ui.live.join.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
@ -427,6 +487,66 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
}
}
/// Open the Publish to server dialog for the open file (the last address used this run in it).
pub fn open_publish(app: &mut FigApp) {
if !app.session.live.can_host() {
app.toast("Publishing to a server needs the desktop app; in the browser you can join a server's files");
return;
}
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name });
app.ui.live.focus_publish = true;
}
/// Share ▸ Publish to server…: the server's address (with its key) and the file's name.
pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
let Some(mut form) = app.ui.live.publish.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut cancel = false;
let mut go = false;
let focus = std::mem::take(&mut app.ui.live.focus_publish);
crate::dialogs::window("Publish to server", ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(380.0);
widgets::label(
ui,
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view.",
t.text,
);
widgets::label(ui, "Server address", t.text_secondary);
let resp = ui.add(
egui::TextEdit::singleline(&mut form.server)
.hint_text("ws://server:47900/?key=…")
.desired_width(380.0)
.id(egui::Id::new("live_publish_server")),
);
// The address carries the server key: it isn't read out.
a11y::text(&resp, "Server address", "");
if focus {
resp.request_focus();
}
let enter = |ui: &Ui| ui.input(|i| i.key_pressed(egui::Key::Enter));
go = resp.lost_focus() && enter(ui);
widgets::label(ui, "On the server's machine, `figcraft-server key` prints it.", t.text_secondary);
widgets::label(ui, "File name", t.text_secondary);
let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name")));
a11y::text(&nresp, "File name", &form.name);
go |= nresp.lost_focus() && enter(ui);
ui.add_space(4.0);
ui.horizontal(|ui| {
go |= widgets::button(ui, "Publish", true, !form.server.trim().is_empty()).clicked();
cancel = widgets::button(ui, "Cancel", false, true).clicked();
});
});
if go && !form.server.trim().is_empty() && app.run("collab.publish", json!({"server": form.server.trim(), "name": form.name.trim()})).is_some() {
app.ui.live.last_server = form.server.trim().to_string();
return;
}
if open && !cancel {
app.ui.live.publish = Some(form);
}
}
/// Live session state for `ui.inspect`.
pub fn inspect(app: &FigApp) -> serde_json::Value {
let l = &app.session.live;
@ -437,6 +557,8 @@ pub fn inspect(app: &FigApp) -> serde_json::Value {
"follow": l.follow,
"cursorsShown": app.ui.live.shown(),
"joinDialog": app.ui.live.join.is_some(),
"publishDialog": app.ui.live.publish.is_some(),
"readOnly": app.session.active_read_only(),
})
}

View File

@ -102,6 +102,6 @@ fn the_browser_joins_but_does_not_host() {
assert!(h.app.ui.live.join.is_some());
assert!(has_text(
&shapes,
"In the browser you can join sessions hosted by the desktop app. A page opened over https can only reach sessions on this computer."
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)."
));
}

View File

@ -0,0 +1,121 @@
//! Files on a server in the window: the Publish to server dialog (from the File menu and the
//! Share popover; its field takes the keyboard; Escape closes it; publishing leaves the tab
//! read-only until the file is there), the Share popover's address, access and a published file's
//! links, and a view link's "View only" badge with only the moving tools.
use egui::{Event, Key};
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_engine::live::{Access, Event as LiveEvent, Published, Role, SessionInfo, Status, VIEW_ONLY};
use figcraft_engine::tools::ToolId;
use serde_json::json;
use crate::live_tests::{Harness, has_text, text_center};
fn key(h: &mut Harness, key: Key) {
for pressed in [true, false] {
h.frame(vec![Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]);
}
}
fn focused(h: &Harness, id: &str) -> bool {
h.ctx.memory(|m| m.has_focus(egui::Id::new(id)))
}
/// Joined through a server's link with `access`: the welcome and the document (a new tab).
fn joined(h: &mut Harness, access: Access) {
h.app.session.execute("collab.join", &json!({"link": "figcraft://join?host=127.0.0.1:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv"})).unwrap();
let mut server = Server::new(h.app.session.doc().unwrap().clone(), "", Limits::default());
let ClientMsg::Hello(me) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
let (id, outs) = server.join_as(me, access).unwrap();
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
let info = SessionInfo {
role: Role::Guest,
me: id,
name: "Ada".into(),
color: "#14AE5C".into(),
file_name: String::new(),
pages: vec![],
link: String::new(),
url: String::new(),
address: "127.0.0.1:47900".into(),
network: false,
access,
};
h.send(vec![LiveEvent::Welcome { info, peers: vec![] }, LiveEvent::Sync(w)]);
h.frames(2);
assert!(h.app.session.live_shared());
}
#[test]
fn the_publish_dialog() {
let (mut h, _) = Harness::new();
crate::menus::invoke(&mut h.app, "collab.publish");
h.frames(2);
assert_eq!(h.app.ui.live.publish.as_ref().map(|f| f.name.as_str()), Some("Untitled"), "the file's name to start with");
assert!(focused(&h, "live_publish_server"), "the address field takes the keyboard");
key(&mut h, Key::Escape);
assert!(h.app.ui.live.publish.is_none(), "Escape closes it");
// From the Share popover, then publish.
let shapes = h.frames(2);
h.click(text_center(&shapes, "Share"));
let shapes = h.frames(2);
assert!(has_text(&shapes, "Or keep the file on a FigCraft server and share links to edit or to view it."));
h.click(text_center(&shapes, "Publish to server…"));
let shapes = h.frames(2);
assert!(has_text(&shapes, "On the server's machine, `figcraft-server key` prints it."));
h.frame(vec![Event::Text("ws://127.0.0.1:47900/?key=KeyKeyKeyKeyKeyKeyKey0".into())]);
let shapes = h.frames(1);
h.click(text_center(&shapes, "Publish"));
let shapes = h.frames(2);
assert!(h.app.ui.live.publish.is_none(), "it closes once publishing starts");
assert_eq!(h.app.session.live.status, Status::Connecting);
assert!(has_text(&shapes, "Publishing…"), "the toolbar says why the file can't be edited yet");
assert_eq!(crate::live::inspect(&h.app)["readOnly"], figcraft_engine::live::PUBLISHING);
}
#[test]
fn a_published_file_shows_its_links() {
let (mut h, _) = Harness::new();
joined(&mut h, Access::Edit);
h.app.session.live.published = Some(Published {
server: "127.0.0.1:47900".into(),
file: "f".into(),
name: "Team".into(),
edit: "figcraft://join?host=127.0.0.1:47900&file=f&token=EditEditEditEditEdit00".into(),
view: "figcraft://join?host=127.0.0.1:47900&file=f&token=ViewViewViewViewView00".into(),
});
let shapes = h.frames(2);
h.click(text_center(&shapes, "Share"));
let shapes = h.frames(2);
assert!(has_text(&shapes, "Published · nobody else here yet"));
assert!(has_text(&shapes, "127.0.0.1:47900 · Can edit"));
assert!(has_text(&shapes, "Edit link") && has_text(&shapes, "View link"));
assert!(has_text(&shapes, "Leave session"));
}
#[test]
fn a_view_link_shows_the_badge_and_only_the_moving_tools() {
let (mut h, _) = Harness::new();
h.app.session.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
joined(&mut h, Access::View);
let shapes = h.frames(2);
assert!(has_text(&shapes, "View only"), "the badge");
assert_eq!(h.app.session.tool.tool, ToolId::Move);
assert_eq!(crate::live::inspect(&h.app)["readOnly"], VIEW_ONLY);
// A tool's shortcut changes nothing, and says why.
key(&mut h, Key::R);
assert_eq!(h.app.session.tool.tool, ToolId::Move);
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("View only")));
// The Share popover says what the link allows.
let shapes = h.frames(1);
h.click(text_center(&shapes, "Share"));
let shapes = h.frames(2);
assert!(has_text(&shapes, "127.0.0.1:47900 · View only"));
assert!(has_text(&shapes, "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit."));
// Leaving: the copy is yours, the full toolbar is back.
h.click(text_center(&shapes, "Leave session"));
let shapes = h.frames(2);
assert!(!has_text(&shapes, "View only"));
assert!(h.app.session.execute("tool.set", &json!({"tool": "rectangle"})).is_ok());
}

View File

@ -27,6 +27,9 @@ impl LiveLink for Mock {
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
fn publish(&mut self, _: figcraft_engine::live::PublishOptions) -> Result<(), String> {
Ok(())
}
fn leave(&mut self) {}
fn presence(&mut self, _: &Presence) {}
fn send(&mut self, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
@ -115,6 +118,7 @@ impl Harness {
url: String::new(),
address: "127.0.0.1:47900".into(),
network: false,
access: figcraft_engine::live::Access::Edit,
};
self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]);
}

View File

@ -128,6 +128,8 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"edit.findReplace" => app.ui.find = Some((String::new(), String::new())),
// From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly).
"collab.join" => crate::live::open_join(app, ""),
// So is the server address for publishing (it carries the server key).
"collab.publish" => crate::live::open_publish(app),
"edit.copy" | "edit.cut" => {
let ids = app.session.selection();
// SVG for other apps, taken before a cut removes the layers.

View File

@ -255,8 +255,17 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl
/// A menu item with a check mark when `on` (the unchecked ones line up with it).
pub fn menu_check(ui: &mut Ui, on: bool, label: &str, shortcut: &str) -> bool {
let resp = menu_row(ui, &format!("{}{label}", if on { "✓ " } else { " " }), shortcut, 0.0, |_, _, _| {});
a11y::menu_item(&resp, label, shortcut, Some(on));
marked_row(ui, Some(on), label, shortcut)
}
/// A plain menu item lined up with the check items above or below it.
pub fn menu_item_beside_checks(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
marked_row(ui, None, label, shortcut)
}
fn marked_row(ui: &mut Ui, on: Option<bool>, label: &str, shortcut: &str) -> bool {
let resp = menu_row(ui, &format!("{}{label}", if on == Some(true) { "✓ " } else { " " }), shortcut, 0.0, |_, _, _| {});
a11y::menu_item(&resp, label, shortcut, on);
resp.clicked()
}
@ -296,6 +305,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
// Editing a vector with points selected, the points' fields stand in for the layer's position
// and dimensions.
let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty());
let vector_edit_none = vector_edit.is_none();
match vector_edit {
Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve),
None => {
@ -312,6 +322,9 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
crate::layout_panel::layout_section(app, ui, &doc, &sel);
widgets::separator(ui);
}
if vector_edit_none && arc_section(app, ui, &doc, &sel) {
widgets::separator(ui);
}
appearance(app, ui, &doc, &sel);
widgets::separator(ui);
if variable_modes(app, ui, &doc, &sel) {
@ -466,6 +479,8 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
(Icon::Mask, "Use as mask", "object.mask"),
(Icon::Union, "Boolean groups", "bool.union"),
];
// Layers in boolean groups, or one boolean group: the operation they use (checked in the menu).
let current = figcraft_engine::booleans::shown_op(doc, sel);
for (icon, label, cmd) in actions.into_iter().rev() {
let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0));
let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) };
@ -473,21 +488,36 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if bresp.hovered() {
ui.painter().rect_filled(br, CornerRadius::same(5), t.hover);
}
// The boolean button shows the operation in use.
let icon = match (cmd, current) {
("bool.union", Some(op)) => crate::layers::op_icon(op),
_ => icon,
};
icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon);
if cmd == "bool.union" {
menu_popup(&bresp).width(180.0).show(|ui| {
for (l, c) in [
("Union", "bool.union"),
("Subtract", "bool.subtract"),
("Intersect", "bool.intersect"),
("Exclude", "bool.exclude"),
("Flatten", "vector.flatten"),
for (op, c) in [
(BoolOp::Union, "bool.union"),
(BoolOp::Subtract, "bool.subtract"),
(BoolOp::Intersect, "bool.intersect"),
(BoolOp::Exclude, "bool.exclude"),
] {
if menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) {
let key = crate::shortcuts::shortcut(c).unwrap_or("");
let picked = match current {
Some(cur) => menu_check(ui, cur == op, op.label(), key),
None => menu_item(ui, op.label(), key),
};
if picked {
app.run(c, json!({}));
ui.close();
}
}
let key = crate::shortcuts::shortcut("vector.flatten").unwrap_or("");
let flatten = if current.is_some() { menu_item_beside_checks(ui, "Flatten", key) } else { menu_item(ui, "Flatten", key) };
if flatten {
app.run("vector.flatten", json!({}));
ui.close();
}
});
} else if bresp.clicked() {
app.run(cmd, json!({}));
@ -605,6 +635,50 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.add_space(8.0);
}
/// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running
/// the other way) and Ratio (the inner radius), the same values the canvas arc handles drag
/// (`node.set`'s `arcStart` / `arcSweep` / `arcInner`; bindable to number variables, sweep and
/// ratio ones holding percentages). Returns whether it showed.
fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool {
let arcs: Vec<(f64, f64, f64)> = sel
.iter()
.filter_map(|i| match doc.node(*i)?.kind {
NodeKind::Ellipse { start, sweep, inner } => Some((start, sweep, inner)),
_ => None,
})
.collect();
if arcs.is_empty() || arcs.len() != sel.len() {
return false;
}
let ids = sel.to_vec();
let same = |f: &dyn Fn(&(f64, f64, f64)) -> f64| -> Option<f64> {
let v = (f(arcs.first()?) * 100.0).round() / 100.0;
arcs.iter().all(|a| ((f(a) * 100.0).round() / 100.0 - v).abs() < 1e-9).then_some(v)
};
let start = same(&|a| a.0.to_degrees().rem_euclid(360.0));
let sweep = same(&|a| a.1 / std::f64::consts::TAU * 100.0);
let ratio = same(&|a| a.2 * 100.0);
widgets::section_header(ui, "Arc", &[], false);
let col = widgets::col_width(ui.available_width());
let field = |app: &mut FigApp, ui: &mut Ui, key: &str, icon: Icon, name: &str, value: Option<f64>, suffix: &str| {
crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("arc", key)), Lead::Icon(icon, name), value, suffix, col)
};
widgets::sublabels(ui, "Start", Some("Sweep"));
row(ui, 32.0, |ui| {
let e = field(app, ui, "arcStart", Icon::ArcStart, "Arc start", start, "°");
app.field(&ids, "arcStart", e, |v| json!(v));
let e = field(app, ui, "arcSweep", Icon::ArcSweep, "Arc sweep", sweep, "%");
app.field(&ids, "arcSweep", e, |v| json!(v.clamp(-100.0, 100.0) * 3.6));
});
widgets::sublabels(ui, "Ratio", None);
row(ui, 32.0, |ui| {
let e = field(app, ui, "arcInner", Icon::ArcRatio, "Arc ratio", ratio, "%");
app.field(&ids, "arcInner", e, |v| json!((v / 100.0).clamp(0.0, 0.99)));
});
ui.add_space(8.0);
true
}
fn pretty(s: &str) -> String {
match s {
"LeftRight" => "Left & right".into(),
@ -1033,13 +1107,17 @@ pub(crate) fn profile_art(painter: &egui::Painter, rect: Rect, profile: Option<&
let (mut top, mut bottom) = (Vec::new(), Vec::new());
for i in 0..=STEPS {
let t = f64::from(i) / f64::from(STEPS);
let s = profile.map_or(0.5, |p| p.scale_at(t) / max) as f32;
// The path runs left to right: its left side is drawn on top.
let (l, r) = profile.map_or((0.5, 0.5), |p| {
let (l, r) = p.sides_at(t);
(l / max, r / max)
});
let x = rect.min.x + rect.width() * t as f32;
let h = (s * half).max(0.5);
top.push(pos2(x, cy - h));
bottom.push(pos2(x, cy + h));
mesh.colored_vertex(pos2(x, cy - h), col);
mesh.colored_vertex(pos2(x, cy + h), col);
let (hl, hr) = (((l as f32) * half).max(0.5), ((r as f32) * half).max(0.5));
top.push(pos2(x, cy - hl));
bottom.push(pos2(x, cy + hr));
mesh.colored_vertex(pos2(x, cy - hl), col);
mesh.colored_vertex(pos2(x, cy + hr), col);
}
for i in 0..STEPS {
let k = 2 * i;
@ -1088,7 +1166,8 @@ fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Opt
if flip.clicked()
&& let Some(p) = custom
{
let pts: Vec<[f64; 2]> = p.points.iter().rev().map(|q| [1.0 - q.t, q.scale]).collect();
let pts: Vec<Value> =
p.points.iter().rev().map(|q| figcraft_engine::cmd::props::width_point_json(&WidthPoint { t: 1.0 - q.t, ..*q })).collect();
app.run("stroke.widthProfile", json!({"ids": ids, "points": pts}));
}
});

View File

@ -52,6 +52,11 @@ fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId {
pub fn show(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let canvas = app.canvas.rect;
// A file you can't edit now (view only, or on its way to a server): moving around only.
if let Some(why) = app.session.active_read_only() {
view_only_toolbar(app, ui, why);
return;
}
// Vector edit mode swaps the tools for the edit tools.
if let Some(tool) = app.session.ds().ok().and_then(|d| d.vector_edit.as_ref()).map(|v| v.tool) {
edit_toolbar(app, ui, tool);
@ -182,6 +187,43 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
});
}
/// The pill shown for a file you can't edit now: Move and Hand, and a badge saying why ("View
/// only", or "Publishing…" until the file is on the server).
fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
let t = Tokens::get(ui.ctx());
// The editing tools are off: the tool falls back to moving around.
if !matches!(app.session.tool.tool, ToolId::Move | ToolId::Hand) {
app.run("tool.set", json!({"tool": "move"}));
}
let label = if why == figcraft_engine::live::PUBLISHING { "Publishing…" } else { "View only" };
let cell = 32.0;
let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);
let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0;
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
let active = app.session.tool.tool == tool;
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tool.label(), pretty_shortcut(tool.shortcut())));
if active {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
app.run("tool.set", json!({"tool": tool.id()}));
}
x += cell + 4.0;
}
divider(ui, &t, x, cy);
let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0));
let resp = ui.interact(badge, ui.id().with("view_only"), Sense::hover()).on_hover_text(why);
a11y::status(&resp, label, why);
ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);
icons::paint(ui.painter(), Rect::from_center_size(pos2(badge.min.x + 12.0 + 7.0, cy), vec2(14.0, 14.0)), Icon::Eye, t.icon_secondary);
ui.painter().galley(pos2(badge.min.x + 12.0 + 14.0 + 6.0, cy - text.size().y / 2.0), text, t.text);
}
/// The pill shown in vector edit mode: the edit tools and Done.
fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
let t = Tokens::get(ui.ctx());

View File

@ -53,6 +53,23 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon, "Width"), Some(round(p.scale * weight)), "", col, 1.0);
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "width": v.max(0.0)}));
});
// Each side's reach from the centre line (Alt-drag a handle on the canvas for one side).
let (l, r) = p.sides();
widgets::sublabels(ui, "Left side", Some("Right side"));
row(ui, 32.0, |ui| {
for (side, scale, name) in [("left", l, "Left side width"), ("right", r, "Right side width")] {
let e = widgets::number_field(
ui,
Id::new(("wpt_side", side)),
Lead::Icon(Icon::StrokeIcon, name),
Some(round(scale * weight / 2.0)),
"",
col,
1.0,
);
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "side": side, "width": v.max(0.0)}));
}
});
ui.add_space(8.0);
}

View File

@ -0,0 +1,105 @@
//! The Design panel's Arc fields (named, valued, Mixed across ellipses, typed into), the width
//! point's side fields, and operands with an operation of their own in the layers panel.
use egui::accesskit::{Node, Role};
use egui::{Event, Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::icons::Icon;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn tree(u: &mut Ui) -> Vec<Node> {
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
}
fn value(nodes: &[Node], label: &str) -> Option<String> {
nodes.iter().find(|n| n.role() == Role::SpinButton && n.label() == Some(label)).map(|n| n.value().unwrap_or_default().to_string())
}
fn press(u: &mut Ui, key: Key) {
u.frame(vec![
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
}
#[test]
fn arc_fields_show_and_set_the_ellipse_arc() {
let mut s = Session::new();
let e = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let r = id(s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("node.set", &json!({"id": e, "props": {"arcStart": 90, "arcSweep": 270, "arcInner": 0.4}})).unwrap();
s.execute("select.set", &json!({"ids": [e]})).unwrap();
let mut u = Ui::new(s);
let nodes = tree(&mut u);
assert_eq!(value(&nodes, "Arc start").as_deref(), Some("90°"));
assert_eq!(value(&nodes, "Arc sweep").as_deref(), Some("75%"));
assert_eq!(value(&nodes, "Arc ratio").as_deref(), Some("40%"));
// Two ellipses with different sweeps: Mixed; with a rectangle in the selection: no Arc fields.
let e2 = id(u.app.session.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 50, "height": 50})).unwrap());
u.app.session.execute("node.set", &json!({"id": e2, "props": {"arcStart": 90, "arcInner": 0.4}})).unwrap();
u.app.session.execute("select.set", &json!({"ids": [e, e2]})).unwrap();
let nodes = tree(&mut u);
assert_eq!(value(&nodes, "Arc start").as_deref(), Some("90°"));
assert_eq!(value(&nodes, "Arc sweep").as_deref(), Some("Mixed"));
u.app.session.execute("select.set", &json!({"ids": [e, r]})).unwrap();
assert_eq!(value(&tree(&mut u), "Arc sweep"), None);
// Typing into Sweep sets it on the ellipse (one step).
u.app.session.execute("select.set", &json!({"ids": [e]})).unwrap();
press(&mut u, Key::F6);
for _ in 0..200 {
if crate::a11y::focused_name(&u.ctx) == "Arc sweep" {
break;
}
press(&mut u, Key::Tab);
}
assert_eq!(crate::a11y::focused_name(&u.ctx), "Arc sweep");
press(&mut u, Key::Enter);
u.frame(vec![Event::Text("50".into())]);
press(&mut u, Key::Enter);
u.frame(vec![]);
let sweep = match u.app.session.doc().unwrap().node(e).unwrap().kind {
figcraft_doc::NodeKind::Ellipse { sweep, .. } => sweep,
_ => 0.0,
};
assert!((sweep - std::f64::consts::PI).abs() < 1e-9, "{sweep}");
}
#[test]
fn a_width_point_shows_its_sides() {
let mut s = Session::new();
let v = id(s.execute("vector.create", &json!({"points": [[0, 50], [100, 50]], "closed": false})).unwrap());
s.execute("node.set", &json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}})).unwrap();
s.execute("vector.edit.begin", &json!({"id": v})).unwrap();
s.execute("vector.edit.tool", &json!({"tool": "width"})).unwrap();
s.execute("vector.edit.widthPoint", &json!({"t": 0.5, "width": 20})).unwrap();
s.execute("vector.edit.widthPoint", &json!({"index": 1, "side": "left", "width": 4})).unwrap();
let mut u = Ui::new(s);
let nodes = tree(&mut u);
assert_eq!(value(&nodes, "Left side width").as_deref(), Some("4"));
assert_eq!(value(&nodes, "Right side width").as_deref(), Some("10"));
assert_eq!(value(&nodes, "Width").as_deref(), Some("14"));
}
#[test]
fn operands_with_their_own_operation_show_it_in_the_layers_panel() {
let mut s = Session::new();
let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let b = id(s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
let g = id(s.execute("bool.subtract", &json!({"ids": [a, b]})).unwrap());
let doc = s.doc().unwrap();
assert_eq!(crate::layers::node_icon(doc, b), Icon::Ellipse, "following the group: its own icon");
assert_eq!(crate::layers::node_icon(doc, g), Icon::Subtract);
s.execute("bool.intersect", &json!({"ids": [b]})).unwrap();
assert_eq!(crate::layers::node_icon(s.doc().unwrap(), b), Icon::Intersect);
assert_eq!(crate::layers::node_icon(s.doc().unwrap(), a), Icon::Rectangle, "the bottom operand's never shows");
}

View File

@ -1,6 +1,6 @@
# FigCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the `plugin` and `collab` crates from #101 and #103) · **Target:** Figma Design
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
@ -29,8 +29,8 @@ replaced.
| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Dev Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
| L6 | `figcraft-ui-egui` | ~31.9k | The UI3-style frontend: canvas (render worker), toolbar, layers, Design/Prototype/Inspect panels, colour picker, menus, dialogs, presenter, comments, accessibility (AccessKit), the control channel server |
| L6 | `figcraft-mcp` | ~2.4k | MCP server (JSON-RPC over stdio), headless or bridged to a running app |
| L6 | `figcraft-collab-net` | ~2.4k | Live sessions over the network: the WebSocket transport (tungstenite, a reader and a writer thread per connection), the session host the app embeds (runs the collaboration core's server), invite links, presence relay and limits; the only crate allowed networking dependencies |
| apps | `figcraft`, `figcraft-cli`, `figcraft-web` | ~1.9k | Desktop app (eframe + wgpu), command line, browser build (WASM, WebGPU/WebGL2) |
| L6 | `figcraft-collab-net` | ~3.0k | Live sessions over the network: the WebSocket transport (tungstenite, a reader and a writer thread per connection), the hub that runs a collaboration core server per open file for the session host the app embeds and for `figcraft-server` (behind a `Backend`), the joining and managing clients (publishing), invite links, presence relay and limits; the browser client; the only crate allowed networking dependencies |
| apps | `figcraft`, `figcraft-cli`, `figcraft-web`, `figcraft-server` | ~3.2k | Desktop app (eframe + wgpu), command line, browser build (WASM, WebGPU/WebGL2), collaboration server (files, share links, persistence; [`server.md`](server.md)) |
| plugins | `plugins/` (own workspace) | | Rust plugin SDK and two example plugins, built for wasm32 by `cargo xtask plugins` ([`plugins.md`](plugins.md)) |
| tools | `xtask` | | `ci`, `layers`, `assets`, `parity`, `version`, `icon`, `wasm`, `plugins`, `bench` |
@ -80,5 +80,6 @@ input.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
| 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) |
| 2026-10-10 | major | Created from the code as of PR #99 |

View File

@ -6,6 +6,10 @@ commands, serve MCP and measure performance. Build it with
Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input.
The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p
figcraft-server`): it keeps files with share links and serves their live sessions, and its
`key`, `files` and `links` subcommands manage them. See [server.md](server.md).
## Subcommands
### `info <file> [--json]`
@ -99,7 +103,8 @@ machines.
### `mcp [--attach PORT]`
Serve MCP on stdio: headless by default, or attached to a running app started with
`figcraft --control PORT`. See [mcp.md](mcp.md).
`figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish
live sessions (`collab.*`); the one-shot `run` can't (no network link).
### `perf [--layers N[,N...]]`

View File

@ -10,8 +10,11 @@ handling and offline queueing with reconnect (`crates/collab`, wired into the en
session and the transport). **Step 4 is implemented**: the browser build joins sessions a desktop
app hosts, and `figcraft://` invite links open the app and join. Styles, variables, collections,
modes and comments created in a session take ids from their creator's id space, and others' text
carets show on the canvas. The rest is the plan the next
implementation PRs follow and update; sections say what is built.
carets show on the canvas. **Step 5 is implemented**: the
standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](server.md)) keeps
many files with persistence and compaction, and share links that let people edit or only view;
the app publishes to it. The rest is the plan the next implementation PRs follow and update;
sections say what is built.
Goal: several people edit the same `.figcraft` file at the same time and see each other's edits,
cursors and selections live, on the desktop app and in the browser, without a central cloud
@ -136,7 +139,7 @@ As built:
hash, the encoded bytes as base64). The page order is the pages' positions.
- **Next**: this file's published library (it embeds a snapshot document; it moves with an asset
channel, together with images, which then stop travelling inside batches); the version history
(kept beside the document, not in it) with the standalone server; finer entries for modes,
(kept beside the document, not in it) on the standalone server; finer entries for modes,
per-mode variable values and comment replies (see Identifiers).
- **Changes** (`Change`): `Create {id, kind, parent, position, props}` (only the properties that
differ from a new node of that kind), `Set {id, prop, value}`, `Delete {id}` (the subtree; a
@ -152,8 +155,9 @@ As built:
## Messages
*Built* (`figcraft_collab::message`): the types below and both encodings. The transport and the
presence UI come in step 2.
*Built* (`figcraft_collab::message`): the types below and both encodings. Protocol version 2 (the
`Hello`'s `protocol`): the welcome says what the client may do, and a connection can manage a
server's files instead of joining one; a client of another version is refused with the reason.
**Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde),
which is compact and needs no schema files. Property values inside are compact JSON text: the
@ -174,17 +178,20 @@ Client → server:
| `Presence {cursor?, selection?, viewport?, editing_text? {node, caret, selection?}}` | Ephemeral; throttled to ~30 Hz, latest wins. |
| `Ack {seq}` | Highest sequence applied (lets the server trim per-client buffers). |
| `Ping` | Keep-alive. |
| `Manage {protocol, key, client_version}` | Instead of `Hello`: manage a server's files with its key. |
| `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. |
Server → client:
| Message | Meaning |
|---|---|
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`). |
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit` or `view` (a view link: every batch is refused). |
| `Changes {seq, author, batch_id, changes}` | Accepted changes, in order, to everyone (the author gets its own back as confirmation). |
| `Reject {batch_id, reason, fixes: [Change]}` | A batch (or part of it) was invalid: the current values to roll back to. |
| `Presence {client_id, …}` / `PeerJoined` / `PeerLeft` | Other people's cursors, selections and arrivals. |
| `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). |
| `Pong` | Keep-alive. |
| `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. |
`Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a
node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put`
@ -352,11 +359,12 @@ resuming with its offline edits, and leaving the session going back to snapshot
`ws://`/`wss://` URL). Others on the same network (or through a forwarded port / VPN such as a
mesh VPN) join with "File ▸ Join live session" or by opening the link. When the host leaves, the
session ends; everyone keeps a local copy.
- **Self-hosted server** (next step): the same server as a `figcraft-server` binary that keeps many
files, persists them (a snapshot plus an append-only log of accepted batches, compacted into new
snapshots), and survives restarts. TLS comes from a reverse proxy (`wss://`).
- **Auth**: an unguessable per-session token in the invite link (first step); accounts and per-file
permissions (view, edit) come with the standalone server.
- **Self-hosted server**: the same server as a `figcraft-server` binary that keeps many files,
persists them (a snapshot plus an append-only log of accepted batches, compacted into new
snapshots), and survives restarts. TLS comes from a reverse proxy (`wss://`). *Built*, below.
- **Auth**: an unguessable per-session token in the invite link (hosting from the app); per-file
share links that let people edit or only view, and a server key that manages files (the
standalone server). User accounts come later.
*Built* (hosting from the app and joining):
@ -426,8 +434,56 @@ resuming with its offline edits, and leaving the session going back to snapshot
page refuses other hosts up front with a message instead of failing obscurely. To join from
other machines, serve the web build over `http://` on a trusted network (locally:
`python3 -m http.server 8767` in the built site, then `http://localhost:8767/` or the machine's
LAN address); `wss://` comes with the standalone server behind TLS. Some browsers also ask
before a public site may reach private network addresses.
LAN address), or use `wss://` links of a `figcraft-server` behind a TLS reverse proxy (the
browser opens them; see *Built (the standalone server)*). Some browsers also ask before a public
site may reach private network addresses.
*Built* (the standalone server, `apps/figcraft-server`; running it: [`server.md`](server.md)):
- **One hub for both hosts.** The app's session host and the server run the same hub
(`crates/collab-net/src/hub.rs`): the listener, a reader and a writer thread per connection,
and one thread owning a core `Server` per open file. What differs sits behind a `Backend`: which
hellos it admits and with what access, where a file that isn't open comes from, what it keeps
of the changes, and the managing connections. The app's backend has one file and its session
token; the server's is its data directory. Hellos carry the token to the backend, which checks
it; the sessions themselves keep no secret.
- **Files and share links.** Each file has links, each with an access (`edit` or `view`), a label
and a 128-bit token stored only as its SHA-256 hash (compared in constant time). A hello names
the file and a link's token; the server joins it with that link's access (`Server::join_as`). A
viewer gets every change and everyone's presence and sends its own presence; each batch it
sends is refused (`Reject`, "you can only view this file") and rolled back. Revoking a link
(or removing a file) disconnects whoever joined with it, with the reason; the link no longer
joins.
- **Links** name the server's endpoint: `figcraft://join?host=<host:port>&file=…&token=…` for a
plain `ws://` endpoint at `/`, `figcraft://join?server=<ws(s)://host[:port][/path]>&file=…&token=…`
for any endpoint (TLS, a path behind a proxy), or `ws(s)://…?file=…&token=…`. The desktop app
can't open `wss://` yet (no TLS of its own); the browser can.
- **Managing files.** A connection that opens with `Manage {key}` instead of `Hello` sends
`Admin` requests: publish a document (its snapshot) as a new file, which returns an edit link
and a view link; list files and links; make and revoke links; remove a file. The server key is
128 bits in the data directory. The server's command line (`figcraft-server files|links|key`)
uses the same requests when a server is running on the directory, else it changes the directory
itself (one process holds it at a time: an OS file lock).
- **Publishing from the app.** `collab.publish {server, name?}` (Share ▸ Publish to server…)
uploads the open document over a managing connection, then joins the new file with its edit
link on the same connection thread; the tab becomes the session's synced document (read-only
until the welcome arrives, its undo history per person after) and `collab.peers` reports
`published {server, file, name, edit, view}`.
- **Persistence.** Each open file's server keeps a journal of what changes its lasting state
(`Server::journaling`: client ids handed out, accepted batches, refused batch ids); the store
appends it to the file's log and syncs it once per burst of messages, before the hub sends what
the server said, so a confirmed edit is never lost to a crash. A restart restores the last
snapshot (`Server::restore`) and replays the log (`Server::replay`; entries already in the
snapshot change nothing, a log that stops making sense is cut there). Compaction writes the
next generation's snapshot (`Server::state`) and an empty log of that generation (a log of an
older generation is from a compaction cut short and is ignored), after 1,000 entries or 16 MiB
of log, when everyone leaves (the file is then closed) and when the server stops. Snapshots and
logs carry CRC-32 checksums; writes go to a temporary file that is flushed and renamed.
Damaged files are skipped and reported at startup, never deleted; a damaged log end is dropped.
- **Restarts.** A stopping server closes connections with code 1012 (service restart), which
clients take as a dropped connection: they reconnect with their usual backoff and resume as the
same clients (the session id, client ids and log come back with the file), sending what they
made meanwhile. The app's session ending (code 1001) stays final.
*Built* (opening invite links):
@ -487,7 +543,8 @@ Decisions, as built:
- **Session state.** `Server::state` returns what a server needs to carry on after a restart
(`SessionState`: snapshot, sequence number, session id, client-id counter, the clients that can
reconnect and what was done with their batches, deleted ids, the log), as one serde value; the
standalone server will store it.
standalone server stores it as each file's snapshot, with a journal of the changes since
(`Server::journaling`, `Server::replay`).
Tests: random sessions where clients drop and come back mid-session converge, with the default log
and with logs short enough that long absences fall back to snapshots; targeted tests cover a lost
@ -496,9 +553,9 @@ saved state.
## Agents, MCP and the CLI
Everything stays a command: `collab.host`, `collab.join`, `collab.leave`, `collab.peers`,
`collab.follow` are ordinary commands, so the CLI, the control channel and MCP can host and join
sessions; an agent can be a participant with its own cursor.
Everything stays a command: `collab.host`, `collab.join`, `collab.publish`, `collab.leave`,
`collab.peers`, `collab.follow` are ordinary commands, so the CLI, the control channel and MCP can
host, publish and join sessions; an agent can be a participant with its own cursor.
*Built*: the desktop app (control channel) and the MCP server's headless session (`figcraft-cli
mcp`) both have the network link installed; `collab.join` returns at once with status
@ -522,12 +579,12 @@ chooses to share on the network; the default is the invite link with a random to
popover says who can join.
- **The token.** 128 bits from the operating system's random number generator, URL-safe base64,
new for every session (stopping and starting again invalidates old links). It is compared in
constant time (the core's `Server`). It is in the invite link by design, so whoever holds the link
constant time (the hub's backend). It is in the invite link by design, so whoever holds the link
can join until the session stops: share it like a password. It never travels in the WebSocket
request (the path is `/`), so proxies don't log it; FigCraft never logs it (logs name addresses
and ports only), and errors about links never echo the link. Traffic is not encrypted (`ws://`):
on a shared network, others on the path could read presence and the token. TLS (`wss://`) comes
with the standalone server behind a reverse proxy; until then, use a trusted LAN or a VPN.
on a shared network, others on the path could read presence and the token. Use a trusted LAN
or a VPN; TLS (`wss://`) comes from a reverse proxy in front of a `figcraft-server`.
- **Limits.** At most 64 open sockets (joined or not) and the core's 256 people; a connection must
finish the WebSocket handshake and say hello within 10 s, and one silent for 45 s is dropped
(clients ping every 15 s); client messages are at most 16 MiB (what the host sends keeps the
@ -536,8 +593,32 @@ chooses to share on the network; the default is the invite link with a random to
outgoing queue fills (it can't keep up); names are cut to 64 characters without control
characters; presence numbers must be finite and selections are cut to 10,000 ids.
- **Malformed input.** Anything that isn't a binary message of the current wire version that
decodes in full is answered with `Refused` and a close; the first message must be `Hello`, for
this file. The invite parser and the host's reader are fuzzed (`crates/collab-net`).
decodes in full is answered with `Refused` and a close; the first message must be `Hello` (for
this file) or, on a server, `Manage`. The invite parser and the host's reader are fuzzed
(`crates/collab-net`), and so are the server's readers of its own files and its port
(`apps/figcraft-server`).
*Built* (the standalone server), and what it adds to the threat model:
- **Who can connect.** Whoever can reach the server's port (127.0.0.1 by default; the operator
chooses an interface or a reverse proxy). Joining a file needs one of its link tokens; managing
files needs the server key. There are no accounts: a link is a bearer secret for one file and
one access level, so share it like a password and revoke it when it leaks; the server key is
the administrator's and should stay with trusted people.
- **Secrets at rest.** Link tokens are kept only as SHA-256 hashes, so a copy of the data
directory can't be used to join (the documents themselves are in it, unencrypted). The server
key is plain text in the data directory (owner-only on Unix): reading the directory already
gives every file. A lost link can't be shown again; revoke it and make a new one.
- **View access is enforced by the server**, not by the app: a viewer's batches are refused
whatever client sends them; the app's read-only state is for clarity.
- **Abuse limits.** On top of the transport's: at most `max_files` files, `max_file_mb` per file
(a publish over it is refused), `max_links` links per file, `max_people` per file and
`max_connections` sockets.
- **TLS.** None in the server: a reverse proxy terminates it. Tokens and keys are never in the
WebSocket request (only in the first message), so proxies don't log them, and the server logs
file and link ids, never secrets.
- **Not yet**: accounts and per-person permissions, per-address rate limits, audit logs,
encryption at rest.
## Implementation plan
@ -558,4 +639,8 @@ chooses to share on the network; the default is the invite link with a random to
carets on the canvas.
4. **Browser client** (WebSocket through `web-sys`). **Done** (the web build joins
desktop-hosted sessions; `figcraft://` links open the desktop app and join).
5. **Standalone `figcraft-server`** with persistence, compaction and accounts.
5. **Standalone `figcraft-server`** with persistence, compaction, share links and permissions.
**Done** (`apps/figcraft-server` on the shared hub; edit and view links, the server key,
publishing from the app; see [`server.md`](server.md)).
6. **Accounts**: people signing in, per-person permissions, teams and projects; TLS in the
desktop app.

View File

@ -1,20 +1,21 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~12% (estimated):** comments and version
history work inside one file; the real-time collaboration core landed in #103 (sync model,
server ordering, client reconciliation, convergence tests), and live sessions (preview) let
several apps co-edit a file hosted by one desktop app, joined from the desktop app or the browser,
with others' cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md)). Remaining: 55–110 h
several apps co-edit a file hosted by one desktop app or kept on a self-hosted `figcraft-server`
with share links to edit or view, joined from the desktop app or the browser, with others'
cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md), [`server.md`](server.md)). Remaining: 55–110 h
(multiplayer 45–90 h, shared libraries 10–20 h), plus branching 8–15 h.
| Feature | Figma | FigCraft | Hours |
|---|---|---|---|
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. Left: standalone server with TLS | 25–55 |
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (Share ▸ Publish to server…). Left: TLS in the desktop app (browsers use `wss://` through a reverse proxy), packaging the server | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **partial, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; no spotlight | 8–15 |
| Cursor chat, audio, high-fives | yes | no | 4–8 |
| Share links, permissions (view / edit), teams and projects | yes | no | 8–17 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. No accounts, teams or projects | 8–17 |
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences | (shared once multiplayer lands) |
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file | — |
| Branching and merging | yes (paid plans) | no | 8–15 |
@ -25,6 +26,7 @@ with others' cursors, selections and text carets shown live ([`collab-protocol.m
| Date | Change | Summary |
|---|---|---|
| 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 | 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) |

View File

@ -90,10 +90,12 @@ pointer press, so the focus ring shows) and the name of the `control` with the k
screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or
the system's accessibility setting turns the interface's animations off. `live` is the live
session as the window shows it: `status`, `role`, the number of `peers`, who you `follow`, the
peers whose cursors are drawn on the canvas (`cursorsShown`) and whether the Join dialog is open
(`joinDialog`; Escape closes it); the session itself is `engine.execute {command: "collab.peers"}`
(hosting and joining are `collab.host` and `collab.join`, opening invite links
`collab.registerLinks`, see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it
peers whose cursors are drawn on the canvas (`cursorsShown`), whether the Join and Publish to
server dialogs are open (`joinDialog`, `publishDialog`; Escape closes them) and why the active
document can't be edited now (`readOnly`: the "View only" or "Publishing" reason, else null); the
session itself is `engine.execute {command: "collab.peers"}` (hosting, joining and publishing are
`collab.host`, `collab.join` and `collab.publish`, opening invite links `collab.registerLinks`,
see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it
was started with (`figcraft "figcraft://join?…"`).
### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}`

View File

@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **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%) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@ -15,7 +15,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| # | Gap | Kind | Hours | Doc |
|---|---|---|---|---|
| 1 | `.fig` files can't be opened (**beta blocker**) | File format | 30–60 | [file-format-parity](file-format-parity.md) |
| 2 | No real-time multiplayer or sharing (**beta blocker**) | Feature | 45–90 | [collaboration-parity](collaboration-parity.md) |
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 45–90 | [collaboration-parity](collaboration-parity.md) |
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
| 5 | No localization layer; English only | Localization | 60–100 | [localization-parity](localization-parity.md) |
@ -26,7 +26,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 10 | Shared (team) libraries | Feature | 10–20 | [components-parity](components-parity.md) |
| 11 | Spell check on Linux, FreeBSD and the web; one language | Feature | 2–5 | [target-app-parity](target-app-parity.md) |
| 12 | Video and animated GIF fills; HEIC/TIFF images | File format | 6–12 | [file-format-parity](file-format-parity.md) |
| 13 | Live boolean editing and the vector tail | Feature | 6–10 | [target-app-parity](target-app-parity.md) |
| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
| 14 | Variables: extended collections, import/export | Feature | 4–8 | [components-parity](components-parity.md) |
| 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) |
| 16 | Dev Mode: Code Connect, editor integrations | Feature | 4–8 | [dev-mode-parity](dev-mode-parity.md) |
@ -54,21 +54,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
model), with a corpus of synthetic `.fig` files saved from the installed Figma. Export to `.fig`
is a further 15–30 h and lower priority.
### 2. No real-time multiplayer or sharing
- **Missing:** co-editing, live cursors and selections, observation mode, cursor chat, spotlight,
audio, share links and permissions. Comments and versions are local to the file.
### 2. Multiplayer and sharing are a preview, without accounts
- **Missing:** accounts, teams and projects, per-person permissions, spotlight, cursor chat, audio,
shared comments and version history, TLS in the desktop app. Comments and versions are local to
the file.
- **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server
ordering and validation, client reconciliation, binary messages, randomized convergence tests over
real commands), and live sessions run over the network (`crates/collab-net`: hosting from the
app, invite links that open the app, document sync between apps, live cursors, selections,
text carets, avatars and follow, and the browser build joins) with per-person undo, refused
edits rolled back with a reason, reconnects that resume and send what was made offline, and
styles, variables and comments that several people create at once all kept; but sessions are
plain `ws://` (no TLS, so an https page can only join on the same computer), and there are no
share links or permissions (`docs/collab-protocol.md`).
styles, variables and comments that several people create at once all kept; a standalone
`figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing
confirmed), takes files published from the app and hands out share links that let people edit
or only view (`docs/server.md`). But there are no accounts (a link is the access), and the
desktop app speaks only plain `ws://` (TLS comes from a reverse proxy, which only the browser
can use so far) (`docs/collab-protocol.md`).
- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work
without it.
- **Estimate:** 45–90 h: sharing and permissions and a standalone server with TLS.
- **Estimate:** 45–90 h: accounts, teams and per-person permissions, TLS in the desktop app,
shared comments and versions (numbers from before the server landed; re-measured next).
### 3. Nothing checked against real Figma files
- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and
@ -163,10 +168,17 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
(help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF (first frame) and BMP.
- **Estimate:** 6–12 h (video playback in the presenter is most of it).
### 13. Live boolean editing and the vector tail
- **Missing:** editing a boolean group's operands in place with live results, Design panel arc
fields, shape builder on strokes and open paths, one-sided variable widths.
- **Estimate:** 6–10 h.
### 13. Boolean groups hit-tested by their operands
- **Landed (2026-10-11):** live boolean editing (operands selected by double-click or Cmd/Ctrl-click;
moving, resizing or editing an operand's points redraws the result during the drag, with every
operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame
recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep,
Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take
part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG,
SVG, PDF and outline stroke).
- **Missing:** a click is tested against a boolean group's operands, not its result, so a click in
a subtracted hole still picks the group.
- **Estimate:** 1–3 h.
### 14. Variables: extended collections, import/export
- **Missing:** extended collections (a library collection extended with overrides), variables
@ -223,6 +235,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 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) |

View File

@ -218,6 +218,16 @@ points: `vector.edit.widthPoint {t, width? (px) | scale?}` adds one, `{index, t?
moves or resizes it, `{action: "remove", index}` deletes it. Rendering, PNG/SVG/PDF export and
`vector.outlineStroke` follow the profile.
One-sided widths: a width point can be wider on one side of the path than the other (left and
right looking along the path, y down). Points are `[t, left, right]` (or `{t, left, right}`) where
each side's scale is what `scale` would be with both sides that wide, so `[t, s]` equals
`[t, s, s]`; `get_node` reports one-sided points that way. `vector.edit.widthPoint {index, side:
left|right, width? | scale?}` changes one side (`width`: the distance from the centre line to that
edge; with `t` it adds a point keeping the other side as it is); a width without `side` makes the
point symmetric again. With `pointer`, `alt: true` while dragging a handle (or the path) changes
that side only; `vector.edit.state` lists `widthPoints` with both handle positions (`sides`:
right, left) and `oneSided`.
### Prototypes
Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep
@ -367,15 +377,35 @@ remove and reorder layers there, unlike the rest of an instance). It is a compon
`get_node` shows `"slot": "<property>"` on slot layers.
### Boolean groups
`bool.union` / `bool.subtract` / `bool.intersect` / `bool.exclude {ids?}` wrap layers in a new
boolean group, except: with one boolean group selected they set its operation (every operand follows
it again), and with layers inside boolean groups they set those operands' own operation. Each
operand above the bottom one combines with the result of the operands below it by its own
operation, or the group's (`node.set {operandOperation: union|subtract|intersect|exclude | null}`
on any operand, or `booleanOperation` on an operand that isn't itself a boolean group; on a group
`booleanOperation` is the group's). `get_node` shows `operandOperation` on operands that have one.
Operands stay editable layers: `select.children` (Enter) or a double-click selects them, `cmd: true`
clicks reach them directly, and moving, resizing or editing an operand's points (`vector.edit.*`)
updates the group's result on every pointer move, one undo step per gesture; a dragged operand
stays in its group. `bool.flatten` bakes the result. Only the operands from the edited one up are
recombined during a drag (`figcraft_render::boolean`; timed by `cargo xtask bench`).
### Shape builder
The selected shapes' outlines split the canvas into regions (areas covered by the same shapes).
`shapeBuilder.regions {ids?}` lists them: an SVG `path` in page coordinates, the `sources` covering
each one (top-most first), `bounds` and a `point` inside. `shapeBuilder.merge {ids?, regions?,
points?}` makes one vector from the regions (by index, or the regions under page points), styled
like the top-most shape over the first one; `shapeBuilder.remove {ids?, regions?, points?}` deletes
them. Either way every shape covering a picked region loses that area (it becomes a vector, or goes
when nothing is left), and each call is one undo step. The tool is `tool.shapeBuilder` (Shift+M):
each one (top-most first), the `parts` covering it (`{id, stroke}`: a shape's fill, or its stroke's
outlined area; top-most first), `bounds` and a `point` inside. Visible strokes take part as their
outlined areas, so stroked lines and open paths split the regions they cross and a stroke's band is a
region of its own. `shapeBuilder.merge {ids?, regions?, points?}` makes one vector from the regions
(by index, or the regions under page points), styled like the top-most fill or stroke over the first
one (a stroke's region is filled with the stroke's paints); `shapeBuilder.remove {ids?, regions?,
points?}` deletes them. Either way every shape covering a picked region loses that area (it becomes
a vector, or goes when nothing is left); the strokes of the shapes involved become outlined vectors
just above them (`outlines` in the result; an open path is replaced by its outline), and each call
is one undo step. The tool is `tool.shapeBuilder` (Shift+M):
click or drag across regions with `pointer` to merge, add `alt` to remove; `shapeBuilder.mode
{mode: merge|remove}` swaps what a plain click does.
@ -510,12 +540,24 @@ joined file opens as a new document).
`collab.peers` does, with `link` (`figcraft://join?host=…&file=…&token=…`) and `url`
(`ws://…/?file=…&token=…`): both carry the session's secret, so share them only with people you
invite. `collab.stop {}` ends it.
- `collab.join {link}` (either form) returns at once with `status: "connecting"`; poll
- `collab.join {link}` (any form: an app's invite, a `figcraft-server` share link, or the
`ws(s)://…?file=…&token=…` URL) returns at once with `status: "connecting"`; poll
`collab.peers` until `status` is `"live"`, or `"failed"` with a `reason` (a wrong token, a full
session, an unreachable host). `collab.leave {}` leaves.
session, a revoked link, an unreachable host). `collab.leave {}` leaves; the synced document
stays open as your copy.
- `collab.publish {server, name?, key?}` puts the active document on a `figcraft-server`
([`server.md`](server.md)) and syncs it from there: `server` is the address
`figcraft-server key` prints (`ws://host:47900/?key=…`; or give `key` separately), `name` the
file's name (default: the document's). It returns at once with `status: "connecting"`; the
document is read-only until the upload is done, then `collab.peers` has `published: {server,
file, name, edit, view}` with the file's first edit link and view link (they carry their
tokens: share them like passwords). Someone joining with the view link gets `access: "view"`:
every edit on the synced document is refused with "View only: …" (the server refuses them too).
- `collab.peers {}` → `{available, canHost (false in the web build), status
(off|connecting|live|reconnecting|failed), reason?,
attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network,
access (edit|view), readOnly (the active document can't be edited now: view only, or being
published), published (a file you published: {server, file, name, edit, view}),
follow, syncing (the document arrived), document (the index of the synced open document),
editing (the text layer you are typing in: {node, layer, caret, selection}, or null), peers: [{id,
name, color, editing (the text layer they are typing in, same shape, or null), presence: {cursor:
@ -726,7 +768,10 @@ guide, along the frame's axis nearer the ruler's.
the start. **Start** turns the arc and keeps its sweep. **Ratio** (the centre, or the inner edge
halfway round a ring) drags out to make a ring, and into the gap to show the other part.
`shift: true` snaps angles to 15° steps and the ratio to 5% steps. Each drag is one undo step
and writes `node.set`'s `arcStart` / `arcSweep` (degrees) and `arcInner` (0–0.99).
and writes `node.set`'s `arcStart` / `arcSweep` (degrees) and `arcInner` (0–0.99). The Design
panel's Arc fields (Start °, Sweep %, Ratio %) write the same keys; number variables bound to
`arcSweep` and `arcInner` (`variable.bind`) hold percentages, as the fields show them, and ones
bound to `arcStart` degrees.
`tool.overlay.arc` holds the readout while dragging (`{handle, label: "Sweep 75%", at}`), and
`tool.overlay.arc_hover` holds the handle under the pointer.
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its

View File

@ -70,6 +70,14 @@ is in the workspace: `packaging/env.sh` sets `BINS`/`HAS_CLI` from that, and `pa
`figcraft --version` prints the version and, in CI builds, the commit (`FIGCRAFT_BUILD_SHA`,
read by `apps/figcraft/build.rs`).
**The collaboration server** (`apps/figcraft-server`, binary `figcraft-server`, see
[`server.md`](server.md)) is in the workspace but not packaged yet: releases don't ship it, and
operators build it with `cargo build --release -p figcraft-server`. Shipping it means a
`figcraft-server-<v>-<platform>-<arch>` archive per desktop platform (a headless binary: no
bundle, installer entry or desktop file), signed like `figcraft-cli` on macOS and Windows; that
is a change to `release.yml` and `packaging/` of its own, not part of `BINS` (which every
desktop package installs).
**Fonts.** Every build job checks out [craft-fonts](https://github.com/storytold/craft-fonts) at
`CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and
`CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail rather than ship without them.

View File

@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **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%) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@ -17,7 +17,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
40–60 h, parallel by tool.
4. **Localization layer**, then Figma's own languages first (ja, ko, es, pt-BR, fr, de): 15–25 h
for the layer, 3–5 h per language plus native review.
5. **Multiplayer M9**: a standalone server with TLS and sharing on top of the live sessions (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, and joining from the browser landed), per [`collab-protocol.md`](collab-protocol.md): 45–90 h.
5. **Multiplayer M9**: accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
## Alpha gate
@ -31,7 +31,7 @@ builders' side-by-side checks with synthetic files; nothing has been run on real
| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — |
| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — |
| Hand off to developers: Dev Mode inspect, code, measurements, asset export, ready for dev | yes | `devinspect_tests.rs`, `component_codegen_tests.rs`, `devcompare_tests.rs`, `annotations_tests.rs` | — |
| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app, joined from the app or the browser) but no share links or per-person undo yet ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 |
| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app or a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, undo per person); no accounts or teams ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 |
| Continue existing Figma work (open `.fig`) | no (beta gap) | no `.fig` reader; REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 30–60 |
**Passes, so FigCraft stays alpha:** every daily design workflow works end to end on FigCraft's
@ -50,12 +50,12 @@ blockers.
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass |
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
| M3 Components & styles depth | ~75% | extended collections, variables import/export, real libraries | 10–20 |
| M4 Vector & text depth | ~70% | live booleans, spell check on Linux and the web, Figma Draw (brushes, pressure, text on a path, patterns) | 24–40 |
| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check on Linux and the web, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme) | standalone server with TLS, sharing | 45–90 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable) | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101) | run-time plugin UI, settings storage, distribution, widgets; Figma plugin compatibility is an owner decision | 25–55 |
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
@ -72,6 +72,8 @@ blockers.
| 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) |

174
docs/server.md Normal file
View File

@ -0,0 +1,174 @@
# figcraft-server
`figcraft-server` keeps FigCraft files for a team and serves their live sessions. A file published
to it stays there when everyone has left; people open it with a **share link** that lets them
**edit** it or only **view** it. Edits are kept as a snapshot plus a log of what changed, so a
restart (or a crash) loses nothing that was confirmed, and apps reconnect where they were. It is
headless, native only (no web build), and runs on the same platforms as the desktop app.
There are no user accounts yet: a share link is the access to one file, and the **server key**
manages files (publishing, links, removal). Accounts and per-person permissions come later.
The protocol and the design behind it are in [`collab-protocol.md`](collab-protocol.md).
## Running it
```sh
cargo build --release -p figcraft-server
target/release/figcraft-server --listen 0.0.0.0:47900 --data /srv/figcraft
```
- `--listen` defaults to `127.0.0.1:47900`: only this machine can connect. `0.0.0.0:47900` (or an
interface's address) lets the network in; put it behind a TLS reverse proxy before you expose
it beyond a trusted network (see below).
- `--data` is the data directory (default `./figcraft-data`), made if missing. One server at a
time uses a directory: a second one refuses to start.
- Logs go to standard output (`RUST_LOG=debug` for more). They name files, link ids and addresses,
never keys or tokens.
- Stop it like any service (Ctrl+C, `systemctl stop`, `kill`). Every change it accepted is on disk
before anyone is told about it, so nothing confirmed is lost; a file's log is folded into a new
snapshot the next time everyone leaves it. Apps that were in a file keep editing and reconnect
once the server is back (for about a minute and a half), as the same participants.
## Publishing a file
```sh
figcraft-server --data /srv/figcraft key
```
prints the address to publish to, `ws://<host>:47900/?key=<server key>`. In the desktop app,
**Share ▸ Publish to server…** (or **File ▸ Publish to server…**) asks for that address and the
file's name. The open file is uploaded as a new file and the tab keeps syncing through the
server; until the upload is done the tab is read-only ("Publishing…" in the toolbar). The Share
popover then shows an **edit link** and a **view link** to copy. Agents do the same with
`collab.publish {server, name?}` ([`mcp.md`](mcp.md#live-sessions)).
Whoever has the server key can publish files and manage every file's links: give it only to
people you trust.
## Joining
Share links look like the desktop app's invite links and join the same way (**File ▸ Join live
session…**, opening the link, or `collab.join {link}`):
- `figcraft://join?host=<host:port>&file=<id>&token=<token>` for a plain `ws://` server;
- `figcraft://join?server=wss://figcraft.example.com/&file=<id>&token=<token>` for a server behind
a TLS reverse proxy or under a path;
- `ws://<host:port>/?file=<id>&token=<token>` or `wss://…` (the same link as a WebSocket URL).
The file opens as a new tab. With a **view link** you see everyone's changes and presence, and
others see your cursor and selection, but you can't change anything: the toolbar shows a **View
only** badge with only the Move and Hand tools, edits are refused with a message (and the server
refuses them too). **Leave session** keeps the file open as your own copy, editable again.
## Managing files and links
| Command | What it does |
|---|---|
| `figcraft-server key [--rotate]` | The address to publish to, with the key; `--rotate` makes a new key (the server must be stopped) |
| `figcraft-server files [list]` | The files: id, name, people in it now, last change, size |
| `figcraft-server files remove FILE` | Remove a file and its links for good (whoever is in it is disconnected) |
| `figcraft-server links list FILE` | A file's links: id, access, created, label |
| `figcraft-server links create FILE --access edit\|view [--label TEXT]` | A new link, printed once |
| `figcraft-server links revoke FILE LINK` | Revoke a link: it no longer joins, and whoever joined with it is disconnected |
Each takes `--data` (or `--config`). When a server is running on the directory, the command asks
it over the same port with the server key (so a revoked link's users are disconnected at once);
otherwise it changes the directory itself. A new link is shown only when it is made: the server
keeps only a hash of its token, so a lost link can't be recovered (revoke it and make another).
Publishing makes an edit link and a view link labelled `Published (edit)` and `Published (view)`.
## Configuration
Every flag is also a key of a TOML file given with `--config server.toml` (flags win; unknown keys
are errors):
| Key | Default | Meaning |
|---|---|---|
| `listen` | `127.0.0.1:47900` | Where to listen |
| `data` | `figcraft-data` | The data directory |
| `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) |
| `max_files` | 1000 | Most files kept |
| `max_file_mb` | 64 | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB |
| `max_links` | 100 | Most share links per file |
| `max_connections` | 512 | Most open connections |
| `max_people` | 256 | Most people in one file at once |
| `compact_after` | 1000 | Compact a file's log into a new snapshot after this many entries… |
| `compact_mb` | 16 | …or once its log is this large (MiB) |
With `listen` on every interface (`0.0.0.0`) and no `public_url`, links name this machine's
network address.
## The data directory
```text
<data>/LOCK held while the server (or a command) uses the directory
<data>/server.json where the running server listens (read by the commands)
<data>/server.key the server key
<data>/files/<id>/meta.json the file's name, when it was created and last changed
<data>/files/<id>/links.json its links: id, access, label, created, SHA-256 of the token
<data>/files/<id>/snapshot.bin its state at the last compaction (with a checksum)
<data>/files/<id>/log.bin what changed since, one checksummed record per change
```
- **Writes are atomic**: a snapshot, a description or a link list is written to a temporary file
beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced
before the server tells anyone about the change.
- **Compaction** writes the file's state as the next snapshot and starts an empty log, after
`compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server
stops. A log left over from a compaction cut short is recognised and ignored (the snapshot holds
it all).
- **Damage is skipped and reported, never deleted.** At startup a folder whose description, links
or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged
record loses only that end (logged when the file opens). Leftover temporary files are removed.
- The server key is stored as plain text (on Unix readable by the owner only): whoever can read
the data directory can read every file anyway. Link tokens are stored only as hashes.
## Backups
Copy the data directory while the server is stopped, or take an atomic snapshot of the
filesystem it lives on (ZFS, Btrfs, LVM, a VSS shadow copy). A plain copy of a running server's
directory can catch a file between its snapshot and its log; it restores, but may lose that
file's changes since its last compaction. To restore, stop the server, put the directory back and
start it: apps that were in a file catch up with a fresh copy (their unsent edits are kept when
the server still knows them, else they are told what couldn't be kept).
## Behind a reverse proxy (TLS)
The server speaks plain WebSocket (`ws://`); TLS comes from a reverse proxy in front of it, which
gives browsers the `wss://` they need on an `https://` page.
- Run the server on the loopback interface (`listen = "127.0.0.1:47900"`), and set
`public_url` to the address people use, for example `wss://figcraft.example.com/`. Links and
the publish address then name the proxy.
- **Caddy**: a site block for your host name whose only directive is a `reverse_proxy` to
`127.0.0.1:47900`. Caddy obtains the certificate and passes WebSocket upgrades through on its
own.
- **nginx**: a `server` block listening on 443 with your certificate, and a `location` that
passes requests to `http://127.0.0.1:47900` over HTTP/1.1 with the `Upgrade` and `Connection`
request headers forwarded (that is what lets the WebSocket upgrade through). Raise the proxy's
read timeout above the server's 45-second idle limit or leave its default (60 s): apps ping
every 15 seconds.
- **Under a path**: the server answers on any path, so the proxy can forward a prefix such as
`/figcraft/` unchanged; set `public_url = "wss://example.com/figcraft/"`.
- Tokens never travel in the WebSocket request (the client sends them inside its first message),
so the proxy's access logs don't record them.
The desktop app has no TLS of its own yet: it can't open `wss://` links. Inside a trusted network
or a VPN, let it reach the server's `ws://` address directly (keep the port closed to the
internet), and use `wss://` links from the browser build.
## Security
- **Access** is a share link (128-bit random token) per file and access level, or the server key
(128-bit) for managing files. Tokens are stored as SHA-256 hashes and every comparison runs in
constant time; revoking a link disconnects whoever joined with it. A view link's batches are
refused by the server whatever the client does.
- **Transport**: plain `ws://` on the server's port; anyone on the network path can read traffic
(presence, changes, tokens) unless TLS terminates in front of it. Use a reverse proxy for
anything beyond a trusted network.
- **Limits**: the transport's (message sizes, 100 messages a second per connection, idle and
handshake timeouts, `max_connections`) and the collaboration core's (layers, changes per batch,
`max_people`), plus `max_files`, `max_file_mb` and `max_links`. Every message is decoded
defensively, and fuzzed in the tests.
- **Not there yet**: user accounts, per-person permissions, rate limits per address, audit logs.

View File

@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **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, macOS)
> **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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@ -16,12 +16,12 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Number | Value | Kind |
|---|---|---|
| Menu/command breadth | **98.5%** (201 / 204 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) |
| Feature breadth (does each Figma Design feature exist, weighted) | **~78%** (core editor areas 1–16: ~90%) | estimated from the area table below |
| Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below |
| Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) |
| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.5% × discounts 0.9 × 0.9 × 0.8) |
| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.7% × discounts 0.9 × 0.9 × 0.8) |
| Remaining to beta | **~200–365 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~390–745 h** one agent (~95–170 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~385–730 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated |
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places.
| 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
@ -97,7 +97,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 1 | Canvas & navigation | 6 | 95% | 72% | Outline mode variants (`view.outlinesSelection`, `view.maskOutlines`), Display P3 on screen, QA at extreme zooms | 3–6 |
| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 |
| 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 |
| 4 | Shapes & vector | 8 | 92% | 66% | Live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 8–14 |
| 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 |
| 5 | Paint | 7 | 85% | 66% | Video and animated GIF fills, wide-gamut canvas, embedded image profiles, HEIC/TIFF images | 5–9 |
| 6 | Strokes | 3 | 90% | 72% | Stroke styles, brush strokes (see 20) | 2–4 |
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
@ -110,22 +110,22 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/TIFF/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
| 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 |
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching; Open recent | 4–8 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow. Left: per-person undo, offline resume, cursor chat, share links and permissions ([collaboration-parity](collaboration-parity.md)) | 45–90 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
| 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
| 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
| | **Weighted (÷113)** | 113 | **~78%** | **~57%** | | **215–427** |
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 95–184 |
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **208–416** |
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 88–173 |
## By dimension (ready for real work)
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth) | 35 | 57% | 75–142 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
| Features (depth) | 35 | 57% | 68–131 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
| Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
| Hardware | 3 | 50% | 15–30 | Trackpad pinch and HiDPI yes; no pen pressure, no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) |
@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
| Ecosystem/plugins | 3 | 20% | 25–55 | Own WASM plugins with a Rust SDK and two examples (#101); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
| **Ready for real work** | 100 | **~47%** | **~390–745** | | |
| **Ready for real work** | 100 | **~47%** | **~385–730** | | |
Dimension hours add up to the full-parity total without double counting: the feature-area rows
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc
localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration
and is left out as an area, but its effect on working with Figma users is counted in discount 3.
**2. Average depth** ("Ready" column, weighted): **66.5%**.
**2. Average depth** ("Ready" column, weighted): **66.7%**.
**3. Discounts for what still stops real work:**
@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte
| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way |
| File exchange with Figma users | ×0.80 | A Figma designer's work lives in shared Figma files. FigCraft can't open `.fig` or write anything Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) |
66.5% × 0.90 × 0.90 × 0.80 = **~43%** (estimated).
66.7% × 0.90 × 0.90 × 0.80 = **~43%** (estimated).
**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments
from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is
@ -254,8 +254,13 @@ Hours in this narrative are the builders' estimates; the tables above supersede
other vectors, the usual layer targets and the pixel grid, with guides and a marker on the point
landed on; Ctrl/Cmd turns it off), arc handles on the canvas (sweep, start and ratio with a
live readout, Shift snapping, closing at the start, the other part from the gap; rotated and
flipped ellipses, any zoom). *Left:* live boolean editing, one-sided widths, Design panel arc
fields. 4–8 h.
flipped ellipses, any zoom), Design panel Arc fields (Start, Sweep, Ratio; bindable), live
boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or
editing an operand's points redraws the result during the drag with every operand outlined;
per-operand operations from the boolean controls; only the operands from the edited one up are
recombined), the shape builder on strokes and open paths (strokes as their outlined areas), and
one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke).
*Left:* boolean groups are hit-tested by their operands rather than their result. 1–3 h.
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
truncation/max lines, font picker with previews and recents, word/line selection, IME commit,
inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the
@ -573,6 +578,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| 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 | 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) |
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged |

View File

@ -1,6 +1,6 @@
# UI and interaction parity
> **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 UI3 (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; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@ -52,10 +52,11 @@ interaction feel not yet compared.
|---|---|---|
| Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | |
| Frame (presets panel, swap orientation), section, slice | done | |
| Rectangle, ellipse (arc handles), polygon, star, line, arrow; Shift/Alt | done | Design panel arc fields missing |
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | |
| Pen: vector networks, branch, close, bend; pencil | done | no pen pressure |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | live boolean editing missing |
| Shape builder (Shift+M), variable width (Shift+W) | done | |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | |
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | hit-tested by the operands, not the result |
| Text: point, auto height, fixed; caret, words, lines, IME; spell check | done | spell check uses the system's checker on Windows and macOS only, in its language |
| Eyedropper (I) | done | |
| Comment (C), Shift+C to show/hide | done | local only |
@ -91,4 +92,5 @@ interaction feel not yet compared.
| 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 | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |