diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587..01cb9cd6 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- - name: nix flake check --no-build
- run: |
- if [ -f flake.nix ]; then
- nix flake check --no-build --all-systems
- else
- echo "No flake.nix present in this repository; skipping check."
- fi
+ nix:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
diff --git a/Cargo.lock b/Cargo.lock
index aead39d1..86bccfa3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -259,6 +259,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
+ "wl-clipboard-rs",
"x11rb",
]
@@ -855,6 +856,11 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-docpath"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1407,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"arboard",
"eframe",
@@ -1421,17 +1427,20 @@ dependencies = [
"fmv-macos-events",
"image",
"log",
+ "raw-window-handle",
"rfd",
"serde_json",
"wat",
+ "wayland-client",
"webbrowser",
"windows",
+ "winit",
"winresource",
]
[[package]]
name = "figcraft-cli"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1445,7 +1454,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1460,7 +1469,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1483,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1492,7 +1501,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1504,9 +1513,10 @@ dependencies = [
[[package]]
name = "figcraft-engine"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"base64 0.22.1",
+ "craft-docpath",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1531,7 +1541,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1546,7 +1556,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1554,7 +1564,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1562,7 +1572,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1578,7 +1588,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1587,7 +1597,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1599,12 +1609,12 @@ dependencies = [
"krilla",
"log",
"thiserror 2.0.21",
- "tiny-skia",
+ "tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-plugin"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1617,7 +1627,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1630,12 +1640,12 @@ dependencies = [
"moxcms",
"png",
"rayon",
- "tiny-skia",
+ "tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-server"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1659,7 +1669,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1675,7 +1685,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1689,7 +1699,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1714,7 +1724,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -1734,6 +1744,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
[[package]]
name = "flate2"
version = "1.1.10"
@@ -2563,7 +2579,7 @@ dependencies = [
"skrifa 0.42.1",
"smallvec",
"subsetter",
- "tiny-skia-path",
+ "tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
@@ -2820,6 +2836,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3204,6 +3229,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -3264,6 +3299,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
@@ -3981,6 +4027,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "log",
+ "smithay-client-toolkit 0.19.2",
+ "tiny-skia 0.11.4",
+]
+
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -4440,6 +4497,20 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path 0.11.4",
+]
+
[[package]]
name = "tiny-skia"
version = "0.12.0"
@@ -4452,7 +4523,18 @@ dependencies = [
"cfg-if",
"log",
"png",
- "tiny-skia-path",
+ "tiny-skia-path 0.12.0",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
]
[[package]]
@@ -4574,6 +4656,17 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "tree_magic_mini"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
+dependencies = [
+ "memchr",
+ "nom",
+ "petgraph",
+]
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -4730,7 +4823,7 @@ dependencies = [
"skrifa 0.44.0",
"strict-num",
"svgtypes",
- "tiny-skia-path",
+ "tiny-skia-path 0.12.0",
"unicode-bidi",
"unicode-script",
"unicode-vo",
@@ -5683,6 +5776,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
+ "sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -5726,6 +5820,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "wl-clipboard-rs"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
+dependencies = [
+ "libc",
+ "log",
+ "os_pipe",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "tree_magic_mini",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+]
+
[[package]]
name = "write-fonts"
version = "0.48.1"
@@ -5822,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"figcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 8f125ac3..932da46f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
-version = "0.6.0"
+version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
+# Which paths stored in a document may be followed (craft-libs).
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
diff --git a/NOTICE b/NOTICE
index 9aaba825..a0baaa27 100644
--- a/NOTICE
+++ b/NOTICE
@@ -20,3 +20,34 @@ licence texts.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
similar permissive licenses); see each crate's metadata.
+
+Linux Wayland window decorations
+-------------------------------
+sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
+commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
+for vector window controls. Its title-font features are disabled: no Cantarell
+font is embedded by this dependency and no font file is added to this repo.
+
+sctk-adwaita's MIT licence:
+
+MIT License
+
+Copyright (c) 2022 Bartłomiej Maryńczak
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index c232df08..2ac24816 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
FigCraft
- Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.
+ Interface design and prototyping: open source, collaborative and built in pure Rust.
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,
in a native app that opens instantly and works offline. Open source, and yours.
@@ -62,7 +62,7 @@
🎛️ Familiar by design
- The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
+ The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
⚡ Native and fast
@@ -262,7 +262,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 49% 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 opens `.fig` files only best effort, 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).
+> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, 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, and hasn't been checked against real design 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).
## Privacy and online sharing
@@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/ROADMAP.md b/ROADMAP.md
index c3c12085..8a587286 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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
@@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
-| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
+| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
@@ -99,25 +99,25 @@ native speakers' reviews of the six language drafts need people.
| Language | Code | Strings | Status |
|---|---|---|---|
| English | en | 100% | full |
-| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
-| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
+| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
+| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
-| French | fr | 100% | draft (needs native review) |
-| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
-| Indonesian | id | 0% | none |
-| Japanese | ja | 100% | draft (needs native review) |
-| German | de | 100% | draft (needs native review) |
-| Korean | ko | 100% | draft (needs native review) |
+| French | fr | 95% | draft (needs native review) |
+| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
+| Indonesian | id | 99% | draft (needs native review) |
+| Japanese | ja | 99% | draft (needs native review) |
+| German | de | 96% | draft (needs native review) |
+| Korean | ko | 95% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
-Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
+Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
-French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
+French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
-IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
+IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
## Upcoming
@@ -131,6 +131,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
+- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
+- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
+- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
@@ -313,6 +316,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
+| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
+| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
+| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
+| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs
index 372082c2..4c162dbb 100644
--- a/apps/figcraft-cli/src/args.rs
+++ b/apps/figcraft-cli/src/args.rs
@@ -1,5 +1,6 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
-//! options, and boolean `--flag`s. Unknown options are usage errors.
+//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
+//! Unknown options are usage errors.
use std::collections::BTreeMap;
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
#[derive(Debug, Default)]
pub struct Args {
pub positional: Vec,
- values: BTreeMap,
+ values: BTreeMap>,
flags: Vec,
}
@@ -34,7 +35,7 @@ impl Args {
Some(v) => v,
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
};
- a.values.insert(name.to_string(), v);
+ a.values.entry(name.to_string()).or_default().push(v);
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
@@ -45,7 +46,12 @@ impl Args {
}
pub fn value(&self, k: &str) -> Option {
- self.values.get(k).cloned()
+ self.values.get(k).and_then(|v| v.last()).cloned()
+ }
+
+ /// Every value of an option that may be given more than once, in order.
+ pub fn values(&self, k: &str) -> &[String] {
+ self.values.get(k).map_or(&[], Vec::as_slice)
}
pub fn flag(&self, k: &str) -> bool {
@@ -62,9 +68,9 @@ impl Args {
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
/// `--bleed-marks`, `--cmyk`.
pub fn print(&self) -> CliResult {
- let bleed = match self.values.get("bleed") {
+ let bleed = match self.value("bleed") {
None => 0.0,
- Some(s) => figcraft_doc::PrintSettings::parse_length(s)
+ Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
};
@@ -72,7 +78,7 @@ impl Args {
}
pub fn scale(&self) -> CliResult {
- match self.values.get("scale") {
+ match self.value("scale") {
None => Ok(1.0),
Some(s) => s
.trim_end_matches('x')
@@ -85,7 +91,7 @@ impl Args {
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult> {
- match self.values.get(k) {
+ match self.value(k) {
None => Ok(default.to_vec()),
Some(s) => s
.split(',')
@@ -110,6 +116,10 @@ mod tests {
assert_eq!(a.value("o").as_deref(), Some("out"));
assert_eq!(a.scale().unwrap(), 2.0);
assert!(a.flag("json"));
+ let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
+ assert_eq!(a.values("root"), ["a", "b"]);
+ assert_eq!(a.value("root").as_deref(), Some("b"));
+ assert!(a.values("none").is_empty());
}
#[test]
diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs
index 4fb485e1..65b19ee4 100644
--- a/apps/figcraft-cli/src/files.rs
+++ b/apps/figcraft-cli/src/files.rs
@@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult {
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
})
.collect();
+ let missing = figcraft_engine::fonts::missing(&doc);
let v = json!({
"file": path,
"name": doc.name,
@@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult {
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"notes": notes,
+ "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(),
});
if as_json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult {
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
}
}
+ for m in &missing {
+ let layers = if m.layers == 1 { "text layer" } else { "text layers" };
+ println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
+ }
Ok(ExitCode::SUCCESS)
}
@@ -333,17 +339,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
}
None => Session::new(),
};
- for (i, (cmd, params)) in steps.iter().enumerate() {
- let r = s.execute(cmd, params);
- s.ui_requests.clear();
- match r {
- Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
- Err(e) => {
- println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
- eprintln!("error: step {i} ({cmd}) failed: {e}");
- return Ok(ExitCode::from(1));
- }
+ // One undo step and all-or-nothing, the way the same commands behave through `command.batch`
+ // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
+ // a step that fails takes the whole script back instead of leaving the earlier steps applied.
+ // The result lines are held back until the script is through, so none of them reports work that
+ // was rolled back.
+ let mut results: Vec = Vec::with_capacity(steps.len());
+ let mut failed_at = 0;
+ let outcome = s.grouped(&format!("run {script}"), |s| {
+ for (i, (cmd, params)) in steps.iter().enumerate() {
+ failed_at = i;
+ let v = s.batch_step(&results, i, cmd, params)?;
+ s.ui_requests.clear();
+ results.push(v);
}
+ Ok(())
+ });
+ if let Err(e) = outcome {
+ let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
+ let i = failed_at;
+ println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
+ eprintln!("error: step {i} ({cmd}) failed: {e}");
+ eprintln!("the script's document changes were rolled back");
+ return Ok(ExitCode::from(1));
+ }
+ for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
+ println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
@@ -422,4 +443,62 @@ mod tests {
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
+
+ fn temp_dir(tag: &str) -> std::path::PathBuf {
+ let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
+ std::fs::create_dir(&dir).unwrap();
+ dir
+ }
+
+ /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
+ /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
+ /// as "no parent" and the layer landed on the page — with every step reporting success.
+ #[test]
+ fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
+ let dir = temp_dir("run-step");
+ let script = dir.join("script.json");
+ std::fs::write(
+ &script,
+ r#"[
+ {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
+ {"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
+ ]"#,
+ )
+ .unwrap();
+ let out = dir.join("out.figcraft");
+ run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
+
+ let mut s = Session::empty();
+ s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
+ let doc = s.doc().unwrap();
+ let page = s.page().unwrap();
+ let top = doc.node(page).unwrap().children.clone();
+ assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
+ assert_eq!(doc.node(top[0]).unwrap().name, "Card");
+ let inner = doc.node(top[0]).unwrap().children.clone();
+ assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
+ assert_eq!(doc.node(inner[0]).unwrap().name, "child");
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ /// A step that fails takes the whole script back: nothing is written, and the failure says so.
+ #[test]
+ fn a_failed_step_leaves_no_output() {
+ let dir = temp_dir("run-rollback");
+ let script = dir.join("script.json");
+ std::fs::write(
+ &script,
+ r#"[
+ {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
+ {"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
+ ]"#,
+ )
+ .unwrap();
+ let out = dir.join("out.figcraft");
+ // The failure is the script's, not the call's: the CLI reports it and exits non-zero.
+ run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
+ assert!(!out.exists(), "a script that failed writes nothing");
+ std::fs::remove_dir_all(dir).unwrap();
+ }
}
diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs
index 590b50f1..281d79dd 100644
--- a/apps/figcraft-cli/src/main.rs
+++ b/apps/figcraft-cli/src/main.rs
@@ -23,7 +23,7 @@ USAGE:
figcraft-cli render -o [--scale N] [--page NAME]
figcraft-cli run [--in ] [--out ]
figcraft-cli commands [--json] [--filter TEXT]
- figcraft-cli mcp [--attach PORT]
+ figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
@@ -42,6 +42,10 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
+`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
+must exist) confine the files the agent's commands touch: reads only inside a read or write root,
+writes only inside a write root, everything else refused. A running app confines itself: start it
+with `figcraft --control PORT` and the same flags (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
fn main() -> ExitCode {
@@ -133,11 +137,23 @@ fn run(argv: &[String]) -> CliResult {
Ok(ExitCode::SUCCESS)
}
"mcp" => {
- let a = Args::parse(rest, &["attach"], &[])?;
+ let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
- mcp(a.value("attach").as_deref())
+ let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
+ let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
+ (true, _) => None,
+ // The app runs the commands: only its own roots can confine them.
+ (false, Some(_)) => {
+ return Err(CliError::Usage(
+ "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
+ .into(),
+ ));
+ }
+ (false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
+ };
+ mcp(a.value("attach").as_deref(), roots)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
@@ -205,7 +221,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult {
Ok(ExitCode::SUCCESS)
}
-fn mcp(attach: Option<&str>) -> CliResult {
+fn mcp(attach: Option<&str>, roots: Option) -> CliResult {
let backend: Box = match attach {
Some(p) => {
let addr = if p.contains(':') {
@@ -220,7 +236,10 @@ fn mcp(attach: Option<&str>) -> CliResult {
None => Box::new(figcraft_mcp::Headless::new()),
};
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
- let mut server = figcraft_mcp::Server::new(backend);
+ if let Some(r) = &roots {
+ eprintln!("file access confined to {}", r.to_json());
+ }
+ let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
let stdin = std::io::stdin();
let stdout = std::io::stdout();
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs
index 9dd52203..7eaf051f 100644
--- a/apps/figcraft-cli/tests/cli.rs
+++ b/apps/figcraft-cli/tests/cli.rs
@@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() {
&["render", "a", "-o"],
&["perf", "--shapes", "lots"],
&["mcp", "--attach", "notaport"],
+ // An attached app confines itself: roots go to `figcraft --control`.
+ &["mcp", "--attach", "7979", "--automation-read-root", "."],
+ &["mcp", "--automation-write-root"],
&["convert", "a"],
] {
let out = bin().args(args).output().unwrap();
@@ -120,6 +123,49 @@ fn mcp_over_stdio() {
assert_eq!(lines[2]["result"]["isError"], false);
}
+#[test]
+fn mcp_over_stdio_confined_to_automation_roots() {
+ let d = tmp("roots");
+ for sub in ["in", "out", "outside"] {
+ std::fs::create_dir_all(d.join(sub)).unwrap();
+ }
+ let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
+ // A root that isn't a folder stops the server before it serves.
+ let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
+ let mut child = bin()
+ .args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null())
+ .spawn()
+ .unwrap();
+ {
+ let stdin = child.stdin.as_mut().unwrap();
+ let call = |id: u32, tool: &str, args: serde_json::Value| {
+ serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
+ };
+ writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
+ writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
+ writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
+ writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
+ writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
+ writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
+ }
+ drop(child.stdin.take());
+ let out = child.wait_with_output().unwrap();
+ assert!(out.status.success());
+ let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
+ assert_eq!(lines.len(), 6);
+ assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
+ let error = |i: usize| lines[i]["result"]["isError"] == true;
+ assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
+ assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
+ assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
+ let _ = std::fs::remove_dir_all(&d);
+}
+
#[test]
fn commands_json_lists_everything() {
let out = bin().args(["commands", "--json"]).output().unwrap();
diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs
index c5901ac5..ef5c638f 100644
--- a/apps/figcraft-cli/tests/fonts.rs
+++ b/apps/figcraft-cli/tests/fonts.rs
@@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() {
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
}
+
+#[test]
+fn info_lists_the_fonts_a_file_uses_that_are_missing() {
+ let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
+ let text =
+ |family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
+ std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
+ let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
+ let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
+ assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
+
+ let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let v: Value = serde_json::from_slice(&out.stdout).unwrap();
+ assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
+
+ let out = cli().arg("info").arg(&file).output().unwrap();
+ let shown = String::from_utf8_lossy(&out.stdout);
+ assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
+}
diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs
index bd587b76..d5f4f761 100644
--- a/apps/figcraft-server/src/config.rs
+++ b/apps/figcraft-server/src/config.rs
@@ -135,9 +135,23 @@ impl Config {
/// abuse contact, or one whose clients would all count as its proxy).
pub fn warnings(&self) -> Vec {
let mut out = Vec::new();
+ let client_max = NetLimits::default().max_server_message >> 20;
+ if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) {
+ out.push(format!(
+ "max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened",
+ self.file_mb(),
+ client_max - 1
+ ));
+ }
if !self.open_publish {
return out;
}
+ if self.file_mb() > 64 {
+ out.push(format!(
+ "open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server",
+ self.file_mb()
+ ));
+ }
if self.operator_contact.trim().is_empty() {
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
}
@@ -168,7 +182,7 @@ impl Config {
return Err("`idle_days` must be from 0 to 3650".into());
}
let terms = self.terms_url.trim();
- if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
+ if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
return Err("`terms_url` must be a web address (https://…)".into());
}
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
@@ -253,8 +267,9 @@ impl Config {
}
}
- /// Largest message a client may send when open to the public: a whole file (or plugin bundle)
- /// and a margin, not the core's 64 MiB.
+ /// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the
+ /// public this is also the cap (strangers' files stay small); a private server never goes below
+ /// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it.
fn open_message(&self) -> usize {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
@@ -265,9 +280,9 @@ impl Config {
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
if !self.open_publish {
- return base;
+ return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
- let max = self.open_message().min(base.max_message_bytes);
+ let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
}
@@ -279,21 +294,24 @@ impl Config {
pub fn net(&self) -> NetLimits {
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
if !self.open_publish {
- return base;
+ let max = base.max_client_message.max(self.open_message());
+ return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base };
}
- let max = self.open_message().min(base.max_client_message);
+ let max = self.open_message();
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
- // eighth of each, and its biggest consumers are dropped first.
- let (queued, reading) = (256usize << 20, 192usize << 20);
+ // eighth of each, and its biggest consumers are dropped first. Each still fits one whole
+ // message, or a file as large as `max_file_mb` could never arrive.
+ let one = max.saturating_add(1 << 20);
+ let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8)));
NetLimits {
max_per_address: self.max_connections_per_ip,
max_client_message: max,
- max_queued_bytes: max.saturating_add(1 << 20),
+ max_queued_bytes: one,
max_queued_block: queued / 8,
max_queued_total: queued,
- max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
+ max_reading_block: one.min(reading / 8),
max_reading_total: reading,
- max_in_flight: 128 << 20,
+ max_in_flight: (128usize << 20).max(one),
reserve_for_sessions: self.max_connections / 8,
request_idle: std::time::Duration::from_secs(20),
..base
diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs
index f58f25b2..af6e0f99 100644
--- a/apps/figcraft-server/src/disk.rs
+++ b/apps/figcraft-server/src/disk.rs
@@ -8,8 +8,10 @@
//! 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).
+//! whose generation isn't the snapshot's is usually from before a compaction that was cut short
+//! (what it holds is in the snapshot already), but no checksum covers either generation, so the
+//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
+//! first damaged record (a write cut short).
use std::fs::{self, File};
use std::io::{self, Read, Write};
diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs
index fea2667e..978d9e8e 100644
--- a/apps/figcraft-server/src/public_tests.rs
+++ b/apps/figcraft-server/src/public_tests.rs
@@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
assert_eq!(open.net().max_client_message, 17 << 20);
- assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
+ assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin");
let w = open.warnings().join("\n");
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
@@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() {
assert!(!format!("{info:?}").contains(&k));
server.stop();
}
+
+#[test]
+fn files_above_64_mib_fit_every_limit() {
+ // `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must
+ // follow it, or the upload is dropped by the transport before the server can say why.
+ for open_publish in [false, true] {
+ let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
+ let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20);
+ assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message);
+ assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}");
+ assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block);
+ assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes);
+ }
+ // A client takes such a file too, but caps what any server may make it buffer at 1 GiB.
+ assert_eq!(NetLimits::default().max_server_message, 1 << 30);
+}
+
+#[test]
+fn large_files_warn_the_operator() {
+ let public_url = Some("wss://figcraft.example.com/".into());
+ let warns = |open_publish, mb| {
+ Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join(
+ "
+",
+ )
+ };
+ assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64));
+ assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65));
+ assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023));
+ assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024));
+}
diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs
index 538adeff..ea9bc626 100644
--- a/apps/figcraft-server/src/store.rs
+++ b/apps/figcraft-server/src/store.rs
@@ -440,7 +440,7 @@ impl Store {
&& 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());
+ log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
}
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
@@ -1126,8 +1126,32 @@ impl Store {
fresh_log = true;
}
}
- // From before a compaction that was cut short: the snapshot has it all.
- Ok(_) => {}
+ // From before a compaction that was cut short, the snapshot has it all; or a generation
+ // field damaged, which no checksum covers. Replaying tells them apart without trusting
+ // it: entries the snapshot holds are skipped, so only edits it lacks change anything.
+ Ok(log) => {
+ let before = server.seq();
+ let mut stopped = None;
+ for entry in log.entries {
+ if let Err(e) = server.replay(entry) {
+ stopped = Some(e);
+ break;
+ }
+ }
+ let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
+ if recovered > 0 {
+ issues.push(format!(
+ "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
+ {recovered} changes the snapshot didn't; they were recovered"
+ ));
+ }
+ if let Some(e) = stopped {
+ issues.push(format!(
+ "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
+ making sense after {recovered} newer changes ({e}); the rest was dropped"
+ ));
+ }
+ }
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
}
for i in issues {
diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/header_recovery.rs
new file mode 100644
index 00000000..d728908d
--- /dev/null
+++ b/apps/figcraft-server/tests/header_recovery.rs
@@ -0,0 +1,139 @@
+//! A generation field damaged after an abrupt stop must not make the server drop a log that still
+//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
+//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
+//! still restores silently.
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use figcraft_collab::message::{Access, ClientMsg};
+use figcraft_collab::{Client, Outgoing, Replica, Server};
+use figcraft_collab_net::hub::Backend;
+use figcraft_doc::Document;
+use figcraft_server::config::Config;
+use figcraft_server::disk::{self, LOG, SNAPSHOT};
+use figcraft_server::store::{FILES, Store};
+use std::fs;
+use std::path::{Path, PathBuf};
+
+struct Fixture(PathBuf);
+
+impl Fixture {
+ fn new() -> Self {
+ let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
+ fs::create_dir(&path).unwrap();
+ Self(path)
+ }
+}
+
+impl Drop for Fixture {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+}
+
+fn page_name(server: &Server) -> String {
+ let doc = server.document();
+ doc.node(doc.first_page().unwrap()).unwrap().name.clone()
+}
+
+/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
+/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
+fn renamed_and_stopped(data: &Path) -> (String, Server) {
+ let mut store = Store::open(data, Config::default().store()).unwrap();
+ let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
+ let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
+ let id = info.id;
+ let mut server = store.open(&id).unwrap();
+ let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
+ let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
+ let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
+ let mut client = Client::join(welcome).unwrap();
+ store.changed(&id, &mut server);
+ let mut edited = client.document().clone();
+ let page = edited.first_page().unwrap();
+ edited.node_mut(page).unwrap().name = "Kept".into();
+ let change = client.edit(&edited).unwrap().unwrap();
+ assert!(!server.receive(client.id(), change).is_empty());
+ store.changed(&id, &mut server);
+ assert!(store.flush().is_empty(), "the edit must reach disk");
+ assert_eq!(page_name(&server), "Kept");
+ let dir = data.join(FILES).join(&id);
+ assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
+ assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
+ drop(store);
+ (id, server)
+}
+
+/// Reopen the data directory: the file's first page name and the problems reported.
+fn reopen(data: &Path, id: &str) -> (String, Vec) {
+ let mut store = Store::open(data, Config::default().store()).unwrap();
+ let server = store.open(id).unwrap();
+ (page_name(&server), store.problems().to_vec())
+}
+
+fn flip(path: &Path, at: usize) {
+ let mut bytes = fs::read(path).unwrap();
+ bytes[at] ^= 1;
+ fs::write(path, bytes).unwrap();
+}
+
+/// The generation field's first byte in both headers (after the magic and the format byte).
+const GENERATION_AT: usize = 5;
+
+#[test]
+fn an_intact_log_restores_its_edits_without_a_report() {
+ let fixture = Fixture::new();
+ let (id, _) = renamed_and_stopped(&fixture.0);
+ let (name, problems) = reopen(&fixture.0, &id);
+ assert_eq!(name, "Kept");
+ assert!(problems.is_empty(), "{problems:?}");
+}
+
+#[test]
+fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
+ let fixture = Fixture::new();
+ let (id, _) = renamed_and_stopped(&fixture.0);
+ let log = fixture.0.join(FILES).join(&id).join(LOG);
+ flip(&log, GENERATION_AT);
+ // The records themselves still read: only the header's generation changed.
+ let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
+ assert!(read.damaged.is_none() && !read.entries.is_empty());
+ let (name, problems) = reopen(&fixture.0, &id);
+ assert_eq!(name, "Kept", "the flushed rename must survive");
+ assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
+ // And it stays: the recovered edit is now in the file's snapshot.
+ assert_eq!(reopen(&fixture.0, &id).0, "Kept");
+}
+
+#[test]
+fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
+ let fixture = Fixture::new();
+ let (id, _) = renamed_and_stopped(&fixture.0);
+ let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
+ flip(&snapshot, GENERATION_AT);
+ assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
+ let (name, problems) = reopen(&fixture.0, &id);
+ assert_eq!(name, "Kept", "the flushed rename must survive");
+ assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
+ assert_eq!(reopen(&fixture.0, &id).0, "Kept");
+}
+
+#[test]
+fn a_damaged_log_format_is_still_reported() {
+ let fixture = Fixture::new();
+ let (id, _) = renamed_and_stopped(&fixture.0);
+ flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
+ let (_, problems) = reopen(&fixture.0, &id);
+ assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
+}
+
+#[test]
+fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
+ let fixture = Fixture::new();
+ let (id, server) = renamed_and_stopped(&fixture.0);
+ // The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
+ let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
+ fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
+ let (name, problems) = reopen(&fixture.0, &id);
+ assert_eq!(name, "Kept");
+ assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
+}
diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs
index 587fd60b..7eed2ffb 100644
--- a/apps/figcraft-web/src/main.rs
+++ b/apps/figcraft-web/src/main.rs
@@ -14,7 +14,16 @@
//! join from other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
+//!
+//! URL fragment: `#join=` joins that live session once the page has
+//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
+//! The link carries the session's secret; a fragment is never sent to the web host, but the address
+//! bar and the browser history keep it, so the URL is as private as the link itself.
+#[cfg(any(target_arch = "wasm32", test))]
+mod query;
+#[cfg(test)]
+mod query_tests;
#[cfg(target_arch = "wasm32")]
mod web;
diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs
new file mode 100644
index 00000000..befeb36b
--- /dev/null
+++ b/apps/figcraft-web/src/query.rs
@@ -0,0 +1,15 @@
+//! The page's URL: the invite link its fragment names.
+
+/// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still
+/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
+/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted
+/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite
+/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is
+/// opened with.
+pub fn join_link(fragment: &str) -> Option<&str> {
+ // An invite link is at most 2 KiB; every byte of it escaped is three.
+ const MAX: usize = 3 * 2048;
+ let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
+ let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
+ fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
+}
diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs
new file mode 100644
index 00000000..261ebaeb
--- /dev/null
+++ b/apps/figcraft-web/src/query_tests.rs
@@ -0,0 +1,29 @@
+//! `#join=` in the page's URL.
+
+use crate::query::join_link;
+
+#[test]
+fn join_takes_the_rest_of_the_fragment() {
+ let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
+ assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s");
+ assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries");
+ assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later");
+ assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #");
+}
+
+#[test]
+fn no_join_without_the_entry() {
+ assert_eq!(join_link(""), None);
+ assert_eq!(join_link("#"), None);
+ assert_eq!(join_link("#join="), None);
+ assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name");
+ assert_eq!(join_link("#xjoin=x&a=1"), None);
+ assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host");
+}
+
+#[test]
+fn an_overlong_link_is_ignored() {
+ let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000));
+ assert_eq!(join_link(&long), None);
+ assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
+}
diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs
index 8cadb013..29213621 100644
--- a/apps/figcraft-web/src/web.rs
+++ b/apps/figcraft-web/src/web.rs
@@ -40,7 +40,13 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
- let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
+ let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
+ // `#join=`: join that session, as opening an invite link does on the desktop.
+ let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
+ if let Some(link) = crate::query::join_link(&hash) {
+ let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
+ figcraft_ui_egui::live::open_invite(&mut fig, &link);
+ }
Ok(Box::new(WebShell { fig }))
}),
)
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
index bafb6da1..fac4842b 100644
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
-arboard = { workspace = true, features = ["image-data"] }
+arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
@@ -43,3 +43,9 @@ wat = { workspace = true }
[build-dependencies]
winresource = "0.1"
+
+[target.'cfg(target_os = "linux")'.dependencies]
+# Use vector window controls without a title-font renderer or embedded Cantarell.
+winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
+raw-window-handle = "0.6"
+wayland-client = "0.31"
diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs
index 6b792094..41ae5cc3 100644
--- a/apps/figcraft/src/apple_events.rs
+++ b/apps/figcraft/src/apple_events.rs
@@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
pub fn open_documents(fig: &mut FigApp, paths: Vec) {
for p in paths {
- if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
+ if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs
index 2e3099cb..3b4136d9 100644
--- a/apps/figcraft/src/args.rs
+++ b/apps/figcraft/src/args.rs
@@ -1,4 +1,5 @@
-//! The command line: `figcraft [--control ] [files…] [figcraft://join?…]`. An invite link
+//! The command line: `figcraft [--control ] [--automation-read-root ]…
+//! [--automation-write-root ]… [files…] [figcraft://join?…]`. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
@@ -11,6 +12,11 @@ pub struct Args {
pub files: Vec,
/// The last invite link given.
pub join: Option,
+ /// `--automation-read-root ` / `--automation-write-root ` (each repeatable; `=` works
+ /// too): the folders the app's file access is confined to. A flag without a value gives `""`,
+ /// which is refused at startup.
+ pub read_roots: Vec,
+ pub write_roots: Vec,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -49,7 +55,15 @@ pub fn parse(args: &[String]) -> Args {
let mut out = Args::default();
let mut it = args.iter();
while let Some(a) = it.next() {
- if a == "--control" {
+ let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
+ let roots = match flag {
+ "--automation-read-root" => Some(&mut out.read_roots),
+ "--automation-write-root" => Some(&mut out.write_roots),
+ _ => None,
+ };
+ if let Some(roots) = roots {
+ roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
+ } else if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if is_invite(a) {
out.join = Some(a.clone());
diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs
index 0ed404c8..2ae29b3c 100644
--- a/apps/figcraft/src/args_tests.rs
+++ b/apps/figcraft/src/args_tests.rs
@@ -10,12 +10,22 @@ fn args(a: &[&str]) -> Vec {
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
- assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
+ assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
}
+#[test]
+fn automation_roots_repeat_and_are_never_files() {
+ let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
+ assert_eq!(a.read_roots, args(&["in", "more"]));
+ assert_eq!(a.write_roots, args(&["out"]));
+ assert_eq!(a.files, args(&["a.figcraft"]));
+ // A flag without its folder is kept as an empty one, which startup refuses.
+ assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
+}
+
#[test]
fn invite_links_join_instead_of_opening() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs
index d243fb91..008170fb 100644
--- a/apps/figcraft/src/control_server_tests.rs
+++ b/apps/figcraft/src/control_server_tests.rs
@@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
- client.shutdown(Shutdown::Both).unwrap();
+ // The server closes this connection after answering an invalid request, and shutting down
+ // a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
+ // returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
+ // the other shutdowns in this file run while the peer is still open and stay checked.
+ let _ = client.shutdown(Shutdown::Both);
workers.pop().unwrap().join().unwrap();
}
diff --git a/apps/figcraft/src/linux_window.rs b/apps/figcraft/src/linux_window.rs
new file mode 100644
index 00000000..f47b61e0
--- /dev/null
+++ b/apps/figcraft/src/linux_window.rs
@@ -0,0 +1,112 @@
+//! Request winit's frame only on Wayland compositors without xdg-decoration.
+//! Desktop/session names cannot identify the backend of an individual window.
+
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::time::Duration;
+
+use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
+use wayland_client::globals::{GlobalListContents, registry_queue_init};
+use wayland_client::protocol::wl_registry;
+use wayland_client::{Connection, Dispatch, QueueHandle};
+
+/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
+/// Never consume, duplicate or query that inherited connection: there may be no
+/// independently addressable display, or WAYLAND_DISPLAY may name another server.
+pub fn display_socket() -> Option {
+ if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
+ return None;
+ }
+ let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
+ let path = PathBuf::from(display);
+ if path.is_absolute() {
+ Some(path)
+ } else {
+ std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
+ }
+}
+
+pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option) -> bool {
+ let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
+ if !wayland {
+ log::debug!("window controls: non-Wayland window, retaining custom controls");
+ return false;
+ }
+ let server_decorations = socket.and_then(probe);
+ log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
+ native_frame(wayland, server_decorations)
+}
+
+fn native_frame(wayland: bool, server_decorations: Option) -> bool {
+ wayland && server_decorations == Some(false)
+}
+
+fn probe(socket: PathBuf) -> Option {
+ // The compositor must not be able to block startup on a registry roundtrip.
+ // On failure/timeout retain FigCraft's own working title bar and buttons.
+ let (send, receive) = mpsc::sync_channel(1);
+ std::thread::Builder::new()
+ .name("wayland-decorations".into())
+ .spawn(move || {
+ let _ = send.send(registry_has_decorations(socket));
+ })
+ .ok()?;
+ receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
+}
+
+fn registry_has_decorations(socket: PathBuf) -> Option {
+ // A fresh connection leaves winit's event queue and inherited descriptors alone.
+ let stream = UnixStream::connect(socket).ok()?;
+ let connection = Connection::from_socket(stream).ok()?;
+ let (globals, _queue) = registry_queue_init::(&connection).ok()?;
+ Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
+}
+
+struct Registry;
+
+impl Dispatch for Registry {
+ fn event(
+ _state: &mut Self,
+ _registry: &wl_registry::WlRegistry,
+ _event: wl_registry::Event,
+ _data: &GlobalListContents,
+ _connection: &Connection,
+ _queue: &QueueHandle,
+ ) {
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn only_wayland_without_server_decorations_uses_the_frame() {
+ for (wayland, server_decorations, expected) in [
+ (false, None, false),
+ (false, Some(false), false),
+ (false, Some(true), false),
+ (true, Some(true), false),
+ (true, Some(false), true),
+ (true, None, false),
+ ] {
+ assert_eq!(super::native_frame(wayland, server_decorations), expected);
+ }
+ }
+
+ #[test]
+ fn an_unavailable_display_keeps_the_custom_controls() {
+ assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
+ }
+
+ #[test]
+ fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
+ let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
+ let listener = std::os::unix::net::UnixListener::bind(&socket)?;
+ let start = std::time::Instant::now();
+ assert_eq!(super::probe(socket.clone()), None);
+ assert!(start.elapsed() < std::time::Duration::from_secs(2));
+ drop(listener);
+ let _ = std::fs::remove_file(socket);
+ Ok(())
+ }
+}
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
index fdcc66f9..ddb70204 100644
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -6,6 +6,11 @@
//! `--control ` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
+//!
+//! `--automation-read-root ` / `--automation-write-root ` (each repeatable) confine the
+//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
+//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
+//! the command line are not. A root that isn't an existing folder stops the app at startup.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
@@ -15,6 +20,8 @@ mod args;
mod control_server;
mod gamepad;
mod graphics;
+#[cfg(target_os = "linux")]
+mod linux_window;
mod recovery;
mod services;
mod startup;
@@ -100,10 +107,13 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
- with_app(|fig| services::save_state(fig));
- if let Some(r) = recovery::lock(&self.recovery).take() {
- r.finish();
- }
+ // With the app out of reach (busy), its recovery copies stay for the next start.
+ let copies = recovery::lock(&self.recovery).take();
+ with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
+ // Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
+ // throws on the content view AccessKit restores (craft-libs#3, #781).
+ #[cfg(target_os = "macos")]
+ std::process::exit(0);
}
}
@@ -119,6 +129,17 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
+ let roots = match (
+ args.read_roots.is_empty() && args.write_roots.is_empty(),
+ figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
+ ) {
+ (true, _) => None,
+ (false, Ok(r)) => Some(Arc::new(r)),
+ (false, Err(e)) => {
+ eprintln!("FigCraft: {e}");
+ std::process::exit(2);
+ }
+ };
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
@@ -143,6 +164,7 @@ fn main() -> eframe::Result {
log::error!("{f}: {e}");
}
}
+ let opened: Vec = session.docs.iter().map(|d| d.uid).collect();
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
// The preferences first: they hold the online files a recovered document may be one of.
@@ -174,7 +196,10 @@ fn main() -> eframe::Result {
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
+ fig.automation_roots = roots;
fig.ui.home = false;
+ // Files named on the command line are the user's too: their missing fonts are listed.
+ fig.ui.font_checks = opened;
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
@@ -192,21 +217,18 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
- // The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
- // window is frameless and FigCraft draws the buttons and resize edges.
- if cfg!(target_os = "macos") {
- viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
- fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset;
- } else {
- viewport = viewport.with_decorations(false);
- fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom;
- }
+ // Start frameless on Linux; the app creator checks the actual display backend
+ // before enabling a client-side frame. Other windows inherit that decision.
+ fig.ui.title_bar = desktop_title_bar();
+ viewport = window_chrome(viewport, fig.ui.title_bar);
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
+ #[cfg(target_os = "linux")]
+ let linux_display = linux_window::display_socket();
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -217,6 +239,11 @@ fn main() -> eframe::Result {
"FigCraft",
options,
Box::new(move |_cc| {
+ #[cfg(target_os = "linux")]
+ if linux_window::needs_native_frame(_cc, linux_display) {
+ let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
+ _cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
+ }
Ok(Box::new(App {
queue,
ctx_slot,
@@ -233,3 +260,28 @@ fn main() -> eframe::Result {
}
result
}
+
+fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
+ use figcraft_ui_egui::TitleBar;
+ if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
+}
+
+fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
+ use figcraft_ui_egui::TitleBar;
+ match title_bar {
+ TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
+ TitleBar::Native => viewport.with_decorations(true),
+ TitleBar::Custom => viewport.with_decorations(false),
+ }
+}
+
+#[cfg(all(test, target_os = "linux"))]
+mod linux_window_tests {
+ #[test]
+ fn linux_starts_with_the_existing_frameless_title_bar() {
+ let bar = super::desktop_title_bar();
+ assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
+ let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
+ assert_eq!(viewport.decorations, Some(false));
+ }
+}
diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs
index b2614598..06b0cbeb 100644
--- a/apps/figcraft/src/recovery.rs
+++ b/apps/figcraft/src/recovery.rs
@@ -120,8 +120,7 @@ impl Recovery {
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
- let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
- lock.try_lock().ok()?;
+ let lock = lock_folder(&dir).ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
@@ -250,6 +249,17 @@ impl Recovery {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
+ /// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
+ /// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
+ /// unsaved documents are copied and the copies stay for the next start, as after a crash.
+ pub fn quit(mut self, s: &Session, asked: bool) {
+ if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
+ return self.finish();
+ }
+ self.write(s, true);
+ self.settle(s);
+ }
+
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
+/// How long the startup waits, once, for folder locks that may free themselves (#334).
+#[cfg(unix)]
+const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
+
+/// Lock the folder `dir` (`WouldBlock` while another process holds it).
+fn lock_folder(dir: &Path) -> Result {
+ let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
+ lock.try_lock()?;
+ Ok(lock)
+}
+
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
/// file isn't recorded any more, or is open already): then it opens as any other copy.
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
}
}
};
+ let (mut free, mut held) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
continue;
}
// Still running (another window of the app), or not ours to take.
- let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
- if lock.try_lock().is_err() {
- continue;
+ match lock_folder(&path) {
+ Ok(lock) => free.push((path, lock)),
+ Err(std::fs::TryLockError::WouldBlock) => held.push(path),
+ Err(_) => {}
}
+ }
+ // A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
+ // lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
+ // once, however many folders are held, and try those again (#334). Windows doesn't pass the
+ // lock to child processes.
+ #[cfg(unix)]
+ if !held.is_empty() {
+ std::thread::sleep(LOCK_WAIT);
+ free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
+ }
+ for (path, lock) in free {
let lock = Arc::new(lock);
let files: Vec = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs
index a3d608f9..0322e237 100644
--- a/apps/figcraft/src/recovery_tests.rs
+++ b/apps/figcraft/src/recovery_tests.rs
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let _ = std::fs::remove_dir_all(&root);
}
+#[test]
+fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
+ let root = root("quitunasked");
+ // macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
+ let s = edited();
+ Recovery::start(&root).unwrap().quit(&s, false);
+ let mut next = Session::empty();
+ assert_eq!(recover(&root, &mut next), 1);
+ let _ = std::fs::remove_dir_all(&root);
+ // Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
+ for (s, asked) in [(edited(), true), (Session::new(), false)] {
+ Recovery::start(&root).unwrap().quit(&s, asked);
+ assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
+ }
+ let _ = std::fs::remove_dir_all(&root);
+}
+
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
let _ = std::fs::remove_dir_all(&root);
}
+/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
+/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
+/// file is open, so a crashed run's copies must still come back once that child has exec'd.
+#[cfg(unix)]
+#[test]
+fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
+ let root = root("fork-window");
+ crashed(&edited(), &root);
+ // Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
+ // exec holds it, and free it shortly after.
+ let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
+ lock.try_lock().unwrap();
+ let freed = std::thread::spawn(move || {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ drop(lock);
+ });
+ // The next start waits for that lock rather than reading the run as still going.
+ let mut next = Session::empty();
+ assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
+ freed.join().unwrap();
+ let _ = std::fs::remove_dir_all(&root);
+}
+
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs
index bd8a4204..7ee41ada 100644
--- a/apps/figcraft/src/services.rs
+++ b/apps/figcraft/src/services.rs
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
-pub fn save_state(app: &FigApp) {
- if let Some(dir) = config_dir() {
- write_prefs(&dir, &app.session.prefs);
+/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
+/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
+/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
+pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option) {
+ if let Some(dir) = dir {
+ write_prefs(dir, &app.session.prefs);
}
+ // Before the sessions end: a recovered online file's copy waits for its session's outcome.
+ if let Some(r) = recovery {
+ r.quit(&app.session, app.ui.quit);
+ }
+ drop(std::mem::take(&mut app.session.live));
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
@@ -223,12 +231,47 @@ fn picked(pick: impl Future