M6: merge main and isolate offline Penpot import/export

This commit is contained in:
Guillermo Bueno
2026-10-27 18:59:49 +01:00
657 changed files with 78194 additions and 37418 deletions
+8
View File
@@ -3,5 +3,13 @@
[env]
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
# The Windows UI Automation adapter can be asked about a node that disappeared in the
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
[target.'cfg(all(windows, target_env = "gnu"))']
rustflags = ["--cfg=windows_slim_errors"]
[alias]
xtask = "run -q -p xtask --"
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+4 -5
View File
@@ -21,11 +21,10 @@ jobs:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
# crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
# pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
# once one commit has both (storytold/craft-fonts#42).
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
+1 -1
View File
@@ -32,4 +32,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+4 -5
View File
@@ -24,13 +24,12 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
# Re-pin once one commit has both (storytold/craft-fonts#42).
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
has-server: true
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
# crates/text/build.rs keeps the web build to Inter.
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+11 -2
View File
@@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
FigCraft filters both families from every build in accordance with its asset policy.
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
## egui dependency font icons
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
Generated
+716 -134
View File
File diff suppressed because it is too large Load Diff
+37 -8
View File
@@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" }
figcraft-render = { path = "crates/render" }
figcraft-svg = { path = "crates/svg" }
figcraft-pdf = { path = "crates/pdf" }
figcraft-vectordrawable = { path = "crates/vectordrawable" }
figcraft-format = { path = "crates/format" }
figcraft-tools = { path = "crates/tools" }
figcraft-collab = { path = "crates/collab" }
@@ -32,11 +33,17 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# 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" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`.
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -45,6 +52,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
same-file = "1.0.6"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
@@ -63,11 +71,15 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
deunicode = "1.6"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha1 = "0.11"
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
@@ -79,9 +91,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
wat = "1.261"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
webbrowser = "1"
@@ -130,7 +142,24 @@ debug = false
strip = true
panic = "abort"
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
# panic while drawing unwinds without aborting.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+17
View File
@@ -51,3 +51,20 @@ 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.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
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.
+13 -3
View File
@@ -121,6 +121,16 @@
<p align="center"><sub><b>Live sessions</b>: three apps on one <code>figcraft-server</code> file, with cursors, selections, cursor chat and spotlight</sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-8-board.png" alt="A board file named Sprint retro on a dot grid: a Sprint 14 retro made from the retrospective template, with Went well, To improve and Next steps sections of sticky notes, vote counts of 3, 2, 1 and 1 on four stickies after a vote has ended, and below it a pull request flow chart from the flow chart template whose shapes are joined by straight and elbow connectors with Yes and No labels; a timer pill in the top right shows 4:30 left with pause, add a minute, stop and Results buttons" width="100%">
<p align="center"><sub><b>Boards</b>: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions</sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-9-devmode-measure.png" alt="Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu" width="100%">
<p align="center"><sub><b>Dev Mode measurements</b>: redlines placed between layers or along a side, saved in the file and following the layers · <code>examples/ridgeline-app.figcraft</code></sub></p>
</td>
</tr>
</table>
## Made in FigCraft
@@ -198,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
@@ -234,8 +244,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
+37 -10
View File
@@ -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-25 · **Change:** minor (M6: offline Penpot import/export using the existing document model, bounded archive and token parsing) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (offline Penpot 2.x import/export; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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,19 +13,20 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. |
| 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)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
@@ -65,7 +66,7 @@ native speakers' reviews of the nine language drafts need people.
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
@@ -89,11 +90,11 @@ native speakers' reviews of the nine language drafts need people.
| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) |
| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
## Languages
@@ -110,11 +111,11 @@ native speakers' reviews of the nine language drafts need people.
| 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 |
| Vietnamese | vi | 100% | draft (needs native review) |
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, Simplified Chinese and Indonesian drafts (Preferences ▸
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese 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,
@@ -132,11 +133,24 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run
unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes
into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget
can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected
(`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets
65%.
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
@@ -153,6 +167,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **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-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
- **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.
@@ -336,9 +351,20 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h |
| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
@@ -401,6 +427,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 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 | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. 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. |
+9
View File
@@ -91,6 +91,15 @@ impl Args {
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
/// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
pub fn vd_compat(&self) -> CliResult<figcraft_doc::VdCompat> {
match self.value("vd-compat") {
None => Ok(figcraft_doc::VdCompat::default()),
Some(s) => figcraft_doc::VdCompat::from_key(&s)
.ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
}
}
pub fn scale(&self) -> CliResult<f64> {
match self.value("scale") {
None => Ok(1.0),
+115 -23
View File
@@ -4,8 +4,8 @@ use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
use crate::{CliError, CliResult};
@@ -31,7 +31,7 @@ fn ext(path: &str) -> String {
fn image_format(e: &str) -> Option<ExportFormat> {
match e {
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
_ => None,
}
}
@@ -240,6 +240,8 @@ pub struct Target {
pub node: Option<String>,
/// PDF only.
pub print: PrintSettings,
/// Android Vector Drawable (XML) only.
pub vd_compat: VdCompat,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
@@ -259,14 +261,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => image_format(&f.to_ascii_lowercase())
.ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig|penpot)")))?,
.ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?,
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of = |ids: &[NodeId]| {
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
// What an Android Vector Drawable changed or left out, like the .fig notes.
for w in warnings {
eprintln!("note: {w}");
}
Ok::<_, CliError>(bytes)
};
if single {
let bytes = match target {
@@ -311,8 +318,34 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
return Err(CliError::Failed(format!(
"{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
)));
}
Ok(())
}
fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
if let Some((input, images_left_out)) = input {
refuse_dropping_images(input, out, images_left_out)?;
}
if let Some(images_left_out) = active.images_lost_saving_to(out) {
refuse_dropping_images(out, out, images_left_out)?;
}
Ok(())
}
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
let doc = load(input)?;
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
refuse_dropping_images(input, out, loaded.images_past_budget)?;
if ext(input) == "penpot" {
for note in &loaded.issues {
eprintln!("note: {note}");
}
}
let doc = loaded.doc;
let page = doc
.current_page
.filter(|p| doc.pages.contains(p))
@@ -337,7 +370,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
Ok(Value::Array(a)) => a,
Ok(Value::Object(o)) if o.contains_key("commands") => {
@@ -362,7 +395,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
Ok((cmd.to_string(), params))
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
})
.collect()
}
@@ -371,43 +405,44 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
let (mut s, input_images_left_out) = match input {
Some(path) => {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
s
let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
(s, left_out)
}
None => Session::new(),
None => (Session::new(), 0),
};
// 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<Value> = Vec::with_capacity(steps.len());
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve 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 scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
for (i, step) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.batch_step(&mut scope, &step.command, &step.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 cmd = steps.get(failed_at).map_or("", |step| step.command.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}));
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
@@ -530,6 +565,43 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
#[test]
fn a_script_refers_to_named_steps_and_paths() {
let dir = temp_dir("run-ref");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
]"#,
)
.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 top = doc.node(s.page().unwrap()).unwrap().children.clone();
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
assert_eq!(names, ["Card", "Copy"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// Two steps with one name fail before anything runs.
#[test]
fn a_script_with_a_name_twice_runs_nothing() {
let dir = temp_dir("run-dup-name");
let script = dir.join("script.json");
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
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() {
@@ -549,4 +621,24 @@ mod tests {
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
let dir = temp_dir("script-images-left-out");
let input = dir.join("big.figcraft");
std::fs::write(&input, b"kept").unwrap();
let link = dir.join("link.figcraft");
std::fs::hard_link(&input, &link).unwrap();
let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
let duplicate = Session::new();
let active = duplicate.ds().unwrap();
for out in [input, link] {
let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
}
assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
std::fs::remove_dir_all(dir).unwrap();
}
}
+18 -10
View File
@@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
@@ -34,11 +34,10 @@ fonts built into FigCraft, for renders that match on every machine. Otherwise in
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x
archive to import into a Penpot team); PDFs take print options (a bleed like 3mm or 0.125in,
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
@@ -116,6 +115,9 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Arg
// Text lays out with the installed fonts, like the desktop app, so headless renders and
// auto layout sizes match what users see.
figcraft_text::scan_system_fonts_on_demand();
} else {
// Nothing scans them then, not even a `.sketch` import matching its fonts.
figcraft_text::opt_out_of_system_fonts();
}
Ok(args)
}
@@ -142,12 +144,18 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
let target = files::Target {
format: a.value("format"),
scale: a.scale()?,
page: a.value("page"),
node: a.value("node"),
print: a.print()?,
vd_compat: a.vd_compat()?,
};
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
+26
View File
@@ -66,3 +66,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
/// A synthetic `.sketch` file: one page with one Helvetica text.
const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA=";
/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to
/// `--no-system-fonts` too: the run still knows only the built-in families.
#[test]
fn sketch_imports_keep_to_no_system_fonts() {
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("open.json");
let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
.arg("run")
.arg(&script)
.arg("--no-system-fonts")
.env_remove("FIGCRAFT_NO_SYSTEM_FONTS")
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines[0]["ok"], json!(true), "{lines:?}");
let fonts: Vec<String> = serde_json::from_value(lines[1]["result"].clone()).unwrap();
assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts");
}
+2
View File
@@ -10,3 +10,5 @@ mod fonts;
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod vector_drawable;
@@ -0,0 +1,41 @@
//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end.
use std::process::Command;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
}
#[test]
fn xml_export_writes_a_drawable_and_prints_its_notes() {
let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("s.jsonl");
std::fs::write(
&script,
r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##,
)
.unwrap();
let file = d.join("doc.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let xml = d.join("icon.xml");
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let text = std::fs::read_to_string(&xml).unwrap();
assert!(text.contains("<vector") && text.contains("android:fillColor=\"#FF0000\""), "{text}");
assert!(String::from_utf8_lossy(&out.stderr).contains("note: drop shadows were left out (Dot)"));
// --format xml into a folder; --vd-compat ignore prints nothing.
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--format", "xml", "--vd-compat", "ignore"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(dir.join("Dot.xml").exists());
assert!(!String::from_utf8_lossy(&out.stderr).contains("note:"));
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).args(["--vd-compat", "sloppy"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("--vd-compat"));
let _ = std::fs::remove_dir_all(&d);
}
+5 -4
View File
@@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
let body = message::encode(state).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
let mut out = Vec::with_capacity(SNAP_HEADER);
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&0u32.to_le_bytes());
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
let sum = crc(&out[SNAP_HEADER..]);
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
Ok(out)
}
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
use super::journal::flush_log;
use super::*;
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
let entry = self.entry(file)?;
let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
}
fn open(&mut self, file: &str) -> Result<Server, String> {
self.load(file).map_err(|e| {
let why = format!("file {file} can't be opened: {e}");
log::error!("{why}");
self.note(why);
"this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
})
}
fn changed(&mut self, file: &str, server: &mut Server) {
let journal = server.take_journal();
if journal.is_empty() {
return;
}
let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
let Some(e) = self.files.get_mut(file) else { return };
let Some(log) = e.log.as_mut() else { return };
for j in &journal {
match disk::record(j) {
Ok(r) => log.pending.extend(r),
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
}
}
log.entries += journal.len();
log.dirty = true;
e.meta.updated = now();
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
// The snapshot holds what's pending too.
self.compact(file, server);
}
}
fn flush(&mut self) -> Vec<String> {
self.flush_all()
}
fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> {
self.room_for(file, server, bytes as u64)
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let compact = self.files.get_mut(file).is_some_and(|entry| {
let snapshot_seq = entry.meta.seq;
entry.log.as_mut().is_some_and(|log| {
// Issued client ids and refusals are durable in the log; only edits need a
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
})
});
if compact {
self.compact(file, server);
}
// It closes anyway, with its state in the snapshot and durable log.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
// Everyone just left: idle time counts from now.
self.touch(file);
true
}
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
self.write_usage(true);
self.flush_all();
for (file, server) in open {
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
self.compact(file, server);
}
}
}
fn manage(&mut self, key: &str) -> Result<(), String> {
match same_secret(key, &self.key) {
true => Ok(()),
false if self.cfg.hosting.open => {
Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into())
}
false => Err("wrong server key".into()),
}
}
fn connect(&mut self, ip: IpAddr) -> Result<(), String> {
if now() >= self.next_sweep {
self.sweep();
self.write_usage(false);
}
if self.blocked.contains(&ip) {
return Err("this server doesn't accept connections from your address".into());
}
if !self.abuse.allow(ip, Action::Connect, Instant::now()) {
return Err("too many connections from your address lately: wait a minute and try again".into());
}
Ok(())
}
fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool {
// Cursors and cursor chat (many people behind one address each send ~30 a second while
// they move) have a budget of their own, beside everything else.
let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message };
self.abuse.allow(ip, what, Instant::now())
}
fn hosting(&mut self, file: &str) -> Option<HostInfo> {
Some(self.host_info(Some(file)))
}
fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result<HostInfo, String> {
if file.is_empty() && owner.is_empty() {
return Ok(self.host_info(None));
}
if !self.owns(file, owner) {
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
}
Ok(self.host_info(Some(file)))
}
fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() });
let file = match &req {
Admin::Publish { name, snapshot } => {
return match self.publish_open(ip, name, snapshot) {
Ok((file, edit, view, owner)) => {
let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta));
AdminReply::Hosted { file, edit, view, owner, expires }.into()
}
Err(e) => {
log::info!("event=publish_refused ip={ip} reason={e:?}");
Handled::from(AdminReply::Failed { reason: e })
}
};
}
Admin::Owned { files } => return self.owned(files).into(),
Admin::Links { file }
| Admin::CreateLink { file, .. }
| Admin::Revoke { file, .. }
| Admin::Rename { file, .. }
| Admin::Code { file, .. }
| Admin::Remove { file } => file,
_ => return failed("that needs the server key"),
};
if owns != Some(file.as_str()) {
return failed("an owner token manages only its own file");
}
self.admin(req, open)
}
fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
self.keep_report(file, link, ip, reason)
}
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
match req {
Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
Err(e) => failed(e),
},
Admin::Files => AdminReply::Files(self.list(open)).into(),
Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
Admin::Revoke { file, link } => match self.revoke(&file, &link) {
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None },
Err(e) => failed(e),
},
Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()),
Admin::Listing => AdminReply::Listing(self.listing(open)).into(),
Admin::Reports => AdminReply::Reports(self.reports.clone()).into(),
Admin::Block { ip } => match self.block(&ip, true) {
Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) },
Err(e) => failed(e),
},
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
Admin::Owned { files } => self.owned(&files).into(),
// The hub removes people itself (it knows who is connected); it never asks the store.
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
Admin::Remove { file } => {
if !self.files.contains_key(&file) {
return failed("this file isn't on the server (it may have been removed)".into());
}
let reply = match self.remove(&file) {
Ok(()) => AdminReply::Done,
Err(reason) => AdminReply::Failed { reason },
};
Handled { reply, revoked: None, removed: Some(file), blocked: None }
}
}
}
fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
let failed = |reason: String| LibraryReply::Failed { reason };
match req {
LibraryReq::Fetch { have, usage } => {
if let Some(u) = usage
&& let Err(e) = self.report_usage(file, u)
{
log::warn!("file {file}: a library usage report couldn't be kept: {e}");
}
self.fetch_library(ip, file, have).unwrap_or_else(failed)
}
LibraryReq::Publish { .. } if access != Access::Edit => {
failed("you can only view this file: publishing its library needs an edit link".into())
}
LibraryReq::Publish { archive } => {
self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version })
}
LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage),
}
}
fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply {
// One address's share first (before everyone's budget is spent or anything is read).
if let PluginReq::Fetch { id, version } = &req
&& let Some(size) = self.directory.size_of(id, version.as_deref())
&& !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now())
{
return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() };
}
self.directory.handle(publisher, req)
}
fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
self.resolve_code(ip, code)
}
fn closing(&self) -> Closing {
Closing::Restarting
}
}
+300
View File
@@ -0,0 +1,300 @@
use super::*;
impl Store {
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
}
/// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
/// caps it gets, and the files and bytes kept by the files published from addresses in it. An
/// address that doesn't parse counts as itself only, ×1, by its exact text.
pub(super) fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
let blocks_of = |text: &str| text.parse::<IpAddr>().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
let kept: Vec<(&str, Vec<_>, u64)> = self
.files
.iter()
.filter_map(|(id, e)| {
let from = e.meta.ip.as_deref()?;
Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
})
.collect();
let sum = |mine: &mut dyn Iterator<Item = &(&str, Vec<_>, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
let blocks = blocks_of(ip);
if blocks.is_empty() {
let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
return vec![(1, files, bytes)];
}
blocks
.into_iter()
.map(|(block, times)| {
let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
(times, files, bytes)
})
.collect()
}
/// Keep a document published without the key from `ip`, within the caps on what one address
/// and everyone may keep: the file, its links and its owner token.
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
let h = &self.cfg.hosting;
let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes);
if !open {
return Err(KEY_ONLY.into());
}
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
return Err("too many files published from your address lately: try again in an hour".into());
}
let blocks = self.per_block(&ip.to_string());
for &(times, files, _) in &blocks {
if files >= max_files.saturating_mul(times as usize) {
return Err(format!(
"your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
));
}
}
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
let fits = |n: u64| {
for &(times, _, used) in &blocks {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
}
}
if used_all.saturating_add(n) > max_all {
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
}
Ok(())
};
let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?;
Ok((info, edit, view, owner.unwrap_or_default()))
}
/// Keep a document as a new file: published without the key from `ip` (it gets an owner
/// token), else by the operator. `fits` checks its size against the caps that apply.
fn keep(
&mut self,
name: &str,
snapshot: &[Change],
ip: Option<IpAddr>,
fits: &dyn Fn(u64) -> Result<(), String>,
) -> Result<(FileInfo, NewLink, NewLink, Option<String>), String> {
if self.files.len() >= self.cfg.max_files {
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
}
if let Some(why) = self.low_disk() {
return Err(why);
}
let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
if replica.document().node_count() > self.cfg.core.max_nodes {
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
}
let mut doc = replica.document().clone();
let name = match clean_name(name) {
n if n.is_empty() => doc.name.clone(),
n => n,
};
doc.name = name.clone();
let server = Server::new(doc, "", self.cfg.core);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(1, &state)?;
if bytes.len() as u64 > self.cfg.max_file_bytes {
return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
}
fits(bytes.len() as u64)?;
let id = loop {
let id = token::file_id()?;
if !self.files.contains_key(&id) && !self.dir(&id).exists() {
break id;
}
};
let t = now();
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
let owner = ip.map(|_| token::secret()).transpose()?;
let meta =
Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 };
let dir = self.dir(&id);
let written = fs::create_dir_all(&dir)
.map_err(|e| e.to_string())
.and_then(|()| {
disk::sync_dir(Some(&self.root.join(FILES)));
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())
})
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
// Last: a folder without its description is an upload cut short.
.and_then(|()| write_json(&dir.join(META), &meta));
if let Err(e) = written {
let _ = fs::remove_dir_all(&dir);
return Err(format!("couldn't keep the file: {e}"));
}
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
self.files.insert(id.clone(), entry);
let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
match ip {
Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()),
None => log::info!("event=published file={id} bytes={} by=key", bytes.len()),
}
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner))
}
/// The files kept, with what the operator moderates by.
pub fn listing(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileRow> {
self.files
.iter()
.map(|(id, e)| FileRow {
info: self.info(id, e, open(id)),
opened: e.meta.opened,
expires: self.expires(&e.meta),
open: e.meta.owner.is_some(),
ip: e.meta.ip.clone().unwrap_or_default(),
reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
})
.collect()
}
/// Owner token `owner` manages file `file` (one published without the key).
pub(super) fn owns(&self, file: &str, owner: &str) -> bool {
let presented = hash(owner);
self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
}
/// What became of the files these `(file id, owner token)` pairs own: each one's name, links
/// and expiry, or `None` when that token manages no file of that id here (which doesn't say
/// whether the id exists). Asking opens nothing: it doesn't count as using a file.
pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
if files.len() > MAX_OWNED {
return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
}
let one = |(file, owner): &(String, String)| {
let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
};
AdminReply::Owned(files.iter().map(one).collect())
}
/// Rename a file (its listing; the document inside keeps its own name).
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
let name = clean_name(name);
if name.is_empty() {
return Err("give the file a name".into());
}
let path = self.dir(file).join(META);
let entry = self.entry(file)?;
let before = std::mem::replace(&mut entry.meta.name, name);
if let Err(e) = write_json(&path, &entry.meta) {
entry.meta.name = before;
return Err(format!("couldn't rename the file: {e}"));
}
Ok(())
}
/// Delete the files published without the key that nobody opened for the idle time (not
/// those open now); their ids.
pub fn sweep(&mut self) -> Vec<String> {
let t = now();
self.next_sweep = t.saturating_add(SWEEP_EVERY);
let due: Vec<String> =
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
for id in &due {
match self.remove(id) {
Ok(()) => log::info!("event=expired file={id}"),
Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
}
}
due
}
/// Make a file look unused for `secs` more (tests: expiry without waiting).
#[cfg(test)]
pub fn age(&mut self, file: &str, secs: u64) {
let path = self.dir(file).join(META);
if let Some(e) = self.files.get_mut(file) {
let m = &mut e.meta;
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
let _ = write_json(&path, m);
}
}
/// Make an open file's log refuse writes (tests: a full or failing disk).
#[cfg(test)]
pub fn break_log(&mut self, file: &str) {
let path = self.dir(file).join(LOG);
if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
&& let Ok(f) = File::open(path)
{
log.file = f;
}
}
/// Note that a file was opened now (it is used: it doesn't expire).
pub(super) fn touch(&mut self, file: &str) {
let path = self.dir(file).join(META);
if let Some(e) = self.files.get_mut(file) {
e.meta.opened = now();
if let Err(err) = write_json(&path, &e.meta) {
log::warn!("file {file}: couldn't update {META}: {err}");
}
}
}
/// What the server says about its hosting (with `file`'s expiry).
pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo {
let h = &self.cfg.hosting;
let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
}
/// Block or unblock an address (an IPv6 address: its /64); the address as kept.
pub fn block(&mut self, ip: &str, on: bool) -> Result<IpAddr, String> {
let ip = ip_key(ip.trim().parse::<IpAddr>().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
}
let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
if changed {
let list: Vec<String> = self.blocked.iter().map(IpAddr::to_string).collect();
write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
}
Ok(ip)
}
/// The blocked addresses.
pub fn blocked(&self) -> Vec<String> {
self.blocked.iter().map(IpAddr::to_string).collect()
}
/// The reports kept, oldest first.
pub fn reports(&self) -> &[ReportInfo] {
&self.reports
}
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
let reason = clean_reason(reason);
if reason.is_empty() {
return Err("say what is wrong with the file".into());
}
let name = self.entry(file)?.meta.name.clone();
if !self.abuse.allow(ip, Action::Report, Instant::now()) {
return Err("too many reports from your address lately: try again in an hour".into());
}
self.reports.push(ReportInfo {
at: now(),
file: file.to_string(),
name,
link: link.unwrap_or_default().to_string(),
ip: ip.to_string(),
reason,
});
if self.reports.len() > MAX_REPORTS {
let extra = self.reports.len() - MAX_REPORTS;
self.reports.drain(..extra);
}
write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
log::info!("event=report file={file} ip={ip}");
Ok(())
}
}
+270
View File
@@ -0,0 +1,270 @@
use super::*;
impl Store {
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
/// without the key its address's share and everyone's. A log that took it over its cap is
/// compacted first (the state may be smaller than its history).
pub(super) fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> {
if let Some(why) = self.low_disk() {
return Err(why);
}
let max = self.cfg.max_file_bytes;
let over = |s: &Store| s.bytes(file).saturating_add(n) > max;
// A compaction to make room at most once a minute per file (it rewrites the whole file).
let t = now();
let may_compact = self.files.get(file).is_some_and(|e| {
t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty())
});
if over(self) && may_compact {
if let Some(e) = self.files.get_mut(file) {
e.forced = t;
}
self.compact(file, server);
}
if over(self) {
return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20));
}
let h = &self.cfg.hosting;
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
for (times, _, used) in self.per_block(&ip) {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
}
}
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
if used_all.saturating_add(n) > max_all {
return Err("this server is full: that change wasn't kept".into());
}
Ok(())
}
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
pub(super) fn load(&mut self, file: &str) -> Result<Server, String> {
self.touch(file);
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
let mut issues = Vec::new();
let mut fresh_log = true;
let mut kept = 0;
let log_path = dir.join(LOG);
// A log that's there but can't be read (an IO error, or over a lowered cap) may hold
// confirmed edits: the file isn't opened, and the log is left as it is for another try.
let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
r => r,
};
// One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
let read = match read.map(|b| disk::read_log(&b)) {
Ok(Err(why)) => {
let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
Err(format!("{why}; it was moved aside to {}", aside.display()))
}
Ok(Ok(log)) => Ok(log),
Err(why) => Err(why),
};
match read {
Ok(log) if log.generation == generation => {
fresh_log = false;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
fresh_log = true;
break;
}
kept += 1;
}
if let Some(why) = log.damaged {
issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
fresh_log = true;
}
}
// From before a compaction that was cut short, the snapshot has it all; 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 {
log::warn!("{i}");
self.note(i);
}
server.journaling(true);
let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
let log = match (fresh_log, file_handle) {
(false, Ok(f)) => {
let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
// Replayed entries make it worth compacting once everyone has left.
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
_ => {
let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
self.rewrite(file, next, &server).map_err(|e| e.why)?
}
};
self.entry(file)?.log = Some(log);
Ok(server)
}
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
/// document (recent batches for reconnecting clients, deletions), which may take it over.
pub(super) fn snapshot_cap(&self) -> u64 {
self.cfg.max_file_bytes.saturating_mul(2)
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
let dir = self.dir(file);
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
let state = server.state().map_err(|e| kept(e.to_string()))?;
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
// and log still hold everything.
if bytes.len() as u64 > self.snapshot_cap() {
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
}
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
// From here the old log no longer goes with the snapshot on disk.
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
if let Ok(e) = self.entry(file) {
e.bytes = (bytes.len() + LOG_HEADER) as u64;
e.meta.seq = server.seq();
let meta = e.meta.clone();
if let Err(err) = write_json(&dir.join(META), &meta) {
log::warn!("file {file}: couldn't update {META}: {err}");
}
}
Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
}
/// Compactions so far.
pub fn compactions(&self) -> u64 {
self.compactions
}
/// Why nothing more may be written when the data directory's disk is nearly full (less free
/// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space
/// can't be told, nothing is refused.
pub(super) fn low_disk(&mut self) -> Option<String> {
let now = Instant::now();
let free = match self.free {
Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free,
_ => {
let free = disk::free_bytes(&self.root);
self.free = Some((now, free));
free
}
};
free.filter(|f| *f < self.cfg.min_free_bytes)
.map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string())
}
/// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept.
pub(super) fn note(&mut self, problem: String) {
if self.problems.len() >= MAX_PROBLEMS {
self.problems.remove(0);
}
self.problems.push(problem);
}
/// Compact an open file: its state as the next snapshot, an empty log.
pub(super) fn compact(&mut self, file: &str, server: &Server) {
self.compactions += 1;
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
let Some(next) = generation.checked_add(1) else {
log::error!("file {file}: compaction failed: the file's generation can't go any higher");
return;
};
match self.rewrite(file, next, server) {
Ok(log) => {
if let Ok(e) = self.entry(file) {
e.log = Some(log);
}
log::debug!("compacted file {file}");
}
// The new snapshot holds everything, but a restart would drop whatever is appended to
// the old log now (its generation no longer matches): the file closes, the hub hears
// it at the next flush, and it opens again from the new snapshot.
Err(RewriteError { why, snapshot_replaced: true }) => {
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
log::error!("file {file}: compaction was cut short: {why}");
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
self.cut_short.push(file.to_string());
}
// The old snapshot and log still hold everything: carry on appending.
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
}
}
/// Write and sync what's pending in every open log; the files whose log couldn't be written.
/// Those are closed here (their pending records dropped: nobody was told of them) and open
/// again from what is on disk.
pub(super) fn flush_all(&mut self) -> Vec<String> {
let mut failed = std::mem::take(&mut self.cut_short);
for (id, e) in &mut self.files {
if let Some(log) = e.log.as_mut() {
match flush_log(id, log) {
Ok(n) => e.bytes += n,
Err(()) => {
e.log = None;
failed.push(id.clone());
}
}
}
}
failed
}
}
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
/// can't be trusted to hold them).
pub(super) fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
if log.pending.is_empty() {
return Ok(0);
}
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
Ok(()) => {
let n = log.pending.len() as u64;
log.bytes += n;
log.pending.clear();
Ok(n)
}
Err(e) => {
log::error!("file {id}: couldn't write its log: {e}");
Err(())
}
}
}
+161
View File
@@ -0,0 +1,161 @@
use super::*;
impl Store {
/// A file's team library for `ip`, unless its version is `have`; within what one address and
/// everyone may download (the size is known before anything is read).
pub(super) fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
if have == Some(lib.version) {
return Ok(LibraryReply::Unchanged { version: lib.version });
}
let path = library_file(&dir, lib.version);
let size = fs::metadata(&path).map_or(0, |m| m.len());
let now = Instant::now();
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
return Err(BUSY_DOWNLOADS.into());
}
Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
}
/// Keep `archive` as a file's team library: it must read as a library (with the limits files
/// using it read it with), be the file's library if it has one, and be a newer version.
// shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile);
// move it to a worker if large libraries make that noticeable.
pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result<u64, String> {
let max = self.cfg.max_file_bytes;
if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() {
return Err(NO_PUBLIC_LIBRARY.into());
}
if archive.len() as u64 > max {
return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
}
// Everything it unpacks to counts against the same cap, so a small archive can't expand
// into a large library in memory.
let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) {
Ok(Some(lib)) => lib,
Ok(None) => return Err("that isn't a published library".into()),
Err(e) => return Err(format!("the library can't be read: {e}")),
};
if lib.doc.node_count() > self.cfg.core.max_nodes {
return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes));
}
let m = &lib.manifest;
let dir = self.dir(file);
let entry = self.entry(file)?;
if let Some(old) = &entry.meta.library {
if old.id != m.id {
return Err(format!("this file's library on the server is another one (\"{}\")", old.name));
}
if m.version <= old.version {
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
}
// A jump would let a client use up the numbers and leave no newer version to publish.
if m.version > old.version.saturating_add(1) {
return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
}
} else if m.version > MAX_FIRST_LIBRARY_VERSION {
return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
}
// Each version has a file of its own, written before the description names it: a failed
// description write or a crash in between leaves the previous version served whole, never
// the new bytes under the old number.
let path = dir.join(library_name(m.version));
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
// Never served (the description doesn't name it); the next publish of it replaces it.
if let Err(r) = fs::remove_file(&path) {
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
}
entry.meta.library = before;
return Err(format!("couldn't keep the library: {e}"));
}
// The previous version isn't served any more.
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
}
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
Ok(m.version)
}
/// A library's usage reports in memory (read from disk the first time; a damaged list starts
/// over: it is only statistics). `None` when the file has no library.
fn usage_of(&mut self, file: &str) -> Result<Option<&mut UsageCache>, String> {
if self.entry(file)?.meta.library.is_none() {
return Ok(None);
}
if !self.usage.contains_key(file) {
let path = self.dir(file).join(USAGE);
let list = if path.exists() {
disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
} else {
Vec::new()
};
self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() });
}
Ok(self.usage.get_mut(file))
}
/// The usage reports kept for a file's library (none when it has no library).
pub fn library_usage(&mut self, file: &str) -> Result<Vec<FileUsage>, String> {
Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default())
}
/// Keep how one file uses a file's library (in memory; written when it changed, at most every
/// [`USAGE_WRITE_EVERY`] seconds).
pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
let name = clean_name(&usage.file);
if name.is_empty() {
return Ok(());
}
let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
components.sort();
components.dedup_by_key(|c| c.0);
components.truncate(MAX_REPORTED);
let Some(u) = self.usage_of(file)? else { return Ok(()) };
match u.list.iter_mut().find(|f| f.usage.file == name) {
Some(f) if f.usage.components == components => return Ok(()),
Some(f) => {
f.usage.components = components;
f.updated = now();
}
None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
}
if u.list.len() > MAX_USERS {
u.list.sort_by_key(|f| std::cmp::Reverse(f.updated));
u.list.truncate(MAX_USERS);
}
u.dirty = true;
if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) {
self.write_usage(false);
}
Ok(())
}
/// Write the usage reports that changed (`all`: whenever they were last written).
pub(super) fn write_usage(&mut self, all: bool) {
let t = now();
let root = self.root.join(FILES);
for (file, u) in &mut self.usage {
if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) {
if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) {
log::warn!("file {file}: library usage couldn't be kept: {e}");
}
(u.dirty, u.written) = (false, t);
}
}
}
}
/// The name of the file holding version `version` of a file's team library.
pub fn library_name(version: u64) -> String {
format!("library-{version}.bin")
}
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
/// version published before each version had one.
fn library_file(dir: &Path, version: u64) -> PathBuf {
let path = dir.join(library_name(version));
if path.exists() { path } else { dir.join(LIBRARY) }
}
+154
View File
@@ -0,0 +1,154 @@
use super::*;
impl Store {
/// The files kept.
pub fn list(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileInfo> {
self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
}
/// A file's links.
pub fn links(&mut self, file: &str) -> Result<Vec<LinkInfo>, String> {
Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
}
/// A new link to a file.
pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result<NewLink, String> {
let max = self.cfg.max_links;
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
if entry.links.len() >= max {
return Err(format!("a file has at most {max} links: revoke one first"));
}
let (link, token) = new_link(access, &clean_name(label), now())?;
entry.links.push(link.clone());
if let Err(e) = write_json(&path, &entry.links) {
entry.links.pop();
return Err(format!("couldn't keep the link: {e}"));
}
Ok(NewLink { link: link.info(file), token })
}
/// The link of `entry` whose token `token` is (every link is compared, in constant time, so
/// timing doesn't tell which one matched).
pub(super) fn link_of(entry: &Entry, token: &str) -> Option<usize> {
let presented = hash(token);
let mut found = None;
for (i, l) in entry.links.iter().enumerate() {
if same_secret(&presented, &l.hash) {
found = Some(i);
}
}
found
}
/// These join codes no longer open anything: typing one says so.
fn forget_codes(&mut self, codes: impl IntoIterator<Item = Sealed>) {
self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
self.gone_codes.drain(..extra);
}
/// Where the link with the join code `key` is: its file, its place among the file's links
/// and its token (every code is compared, so timing doesn't tell which one matched).
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
let mut found = None;
for (id, e) in &self.files {
for (i, l) in e.links.iter().enumerate() {
if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
found = Some((id.clone(), i, token));
}
}
}
found
}
/// A new join code for the link of `file` whose token is `token` (it takes the place of the
/// link's earlier code): the code, shown this once.
pub fn set_code(&mut self, file: &str, token: &str) -> Result<String, String> {
let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
// 45 random bits: one already in use is all but impossible, and never handed out twice.
let (code, key) = loop {
let code = codes::new_code()?;
let key = codes::Key::new(&code);
if self.find_code(&key).is_none() {
break (code, key);
}
};
let sealed = key.seal(token)?;
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
let before = link.code.replace(sealed);
let id = link.id.clone();
if let Err(e) = write_json(&path, &entry.links) {
if let Some(l) = entry.links.get_mut(at) {
l.code = before;
}
return Err(format!("couldn't keep the join code: {e}"));
}
self.forget_codes(before);
log::info!("event=code file={file} link={id}");
Ok(code)
}
/// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
/// the address (see `abuse`), and nothing is looked up for an address that is locked out.
pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
let now = Instant::now();
if let Some(left) = self.abuse.code_locked(ip, now) {
return Err(format!(
"Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
left.as_secs() / 60 + 1
));
}
if !self.abuse.allow(ip, Action::Code, now) {
return Err("Too many codes tried from your address: wait a minute and try again".into());
}
if !self.abuse.codes_open(now) {
return Err(CODES_BUSY.into());
}
// Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
let found = key.as_ref().and_then(|k| self.find_code(k));
self.abuse.code_tried(ip, found.is_some(), now);
match found {
Some((file, _, token)) => {
log::info!("event=code_used file={file} ip={ip}");
Ok((file, token))
}
None => {
let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
Err(if gone { CODE_GONE } else { NO_CODE }.into())
}
}
}
/// Revoke a link.
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
let gone = entry.links.remove(at);
if let Err(e) = write_json(&path, &entry.links) {
entry.links.insert(at, gone);
return Err(format!("couldn't revoke the link: {e}"));
}
log::info!("revoked link {link} of file {file}");
self.forget_codes(gone.code);
Ok(())
}
/// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
/// longer served either way).
pub fn remove(&mut self, file: &str) -> Result<(), String> {
// Closed first: an open log would keep the folder from being deleted on Windows.
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
drop(entry);
let dir = self.dir(file);
fs::remove_dir_all(&dir)
.map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
log::info!("removed file {file}");
Ok(())
}
}
+41
View File
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
#[test]
fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
let dir = TempDir::new("read-only-reconnects");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, original) = {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Read only");
let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
(id, bytes)
};
let mut last_client = 0;
for _ in 0..6 {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let mut server = store.open(&id).unwrap();
let client = join(&mut server, Access::View);
assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
last_client = client.id();
store.changed(&id, &mut server);
// Idle must sync even if no periodic flush ran before the connection closed.
assert!(store.idle(&id, &mut server));
assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
assert!(!log.entries.is_empty());
assert_eq!(server.seq(), 0);
}
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
@@ -252,6 +280,19 @@ fn temporary_directories_are_removed() {
assert!(!path.exists());
}
#[test]
fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
let body = figcraft_collab::message::encode(&state).unwrap();
let mut legacy = b"FCSN\x01".to_vec();
legacy.extend_from_slice(&37u64.to_le_bytes());
legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
legacy.extend_from_slice(&body);
let encoded = disk::encode_snapshot(37, &state).unwrap();
assert_eq!(encoded, legacy);
assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
}
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
+2
View File
@@ -24,6 +24,8 @@ log = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
base64 = { workspace = true }
serde_json = { workspace = true }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+3 -3
View File
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>FigCraft</title>
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
@media (prefers-color-scheme: dark) {
+172
View File
@@ -0,0 +1,172 @@
//! `window.figcraft`: the control channel for the page, as promises.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving FigCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
//! await figcraft.request("document.inspect") // control method → its result
//! await figcraft.commands() // command list
//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64)
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
//! figcraft.captureDownloads(true) // saves stay in the page…
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, TryRecvError};
use base64::Engine as _;
use figcraft_ui_egui::{ControlReply, ControlRequest};
use serde_json::{Value, json};
use wasm_bindgen::prelude::*;
use crate::saved_files::SavedFiles;
type Waiting = (Receiver<ControlReply>, js_sys::Function, js_sys::Function);
thread_local! {
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
pub fn connect(ctx: egui::Context) {
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
/// The page's requests since the last frame, for the app's control queue.
pub fn take_requests() -> Vec<ControlRequest> {
QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(name: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(name, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(ControlReply { result: Ok(v), .. }) => {
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
false
}
Ok(ControlReply { result: Err(e), .. }) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e));
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
}
});
});
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (reply, rx) = std::sync::mpsc::channel();
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() };
QUEUE.with(|q| q.borrow_mut().push(req));
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
});
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
p
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.figcraft`.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}}))
})
.into_js_value(),
)?;
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
set_fn(
&obj,
"files",
Closure::<dyn Fn() -> JsValue>::new(|| {
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
})
.into_js_value(),
)?;
set_fn(
&obj,
"readFile",
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
SAVED.with(|s| {
s.borrow()
.get(&name)
.map(|b| js_sys::Uint8Array::from(b).into())
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
})
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?;
Ok(())
}
+6 -1
View File
@@ -13,17 +13,22 @@
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
//! join from other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//!
//! URL fragment: `#join=<invite link or join code>` 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(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+8 -1
View File
@@ -1,4 +1,4 @@
//! The page's URL: the invite link its fragment names.
//! The page's URL: the invite link its fragment names, and the flags its query sets.
/// The invite link in `#join=<link>` (`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
@@ -13,3 +13,10 @@ pub fn join_link(fragment: &str) -> Option<&str> {
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)
}
/// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`,
/// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only
/// when it is asked for.
pub fn control(search: &str) -> bool {
search.strip_prefix('?').unwrap_or(search).split('&').any(|entry| entry.split('=').next() == Some("control"))
}
+13 -2
View File
@@ -1,6 +1,6 @@
//! `#join=` in the page's URL.
//! `#join=` and `?control` in the page's URL.
use crate::query::join_link;
use crate::query::{control, join_link};
#[test]
fn join_takes_the_rest_of_the_fragment() {
@@ -27,3 +27,14 @@ fn an_overlong_link_is_ignored() {
assert_eq!(join_link(&long), None);
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
}
#[test]
fn control_is_opt_in() {
assert!(control("?control"));
assert!(control("?webgl&control=1"), "after other entries, with a value");
assert!(control("control"), "no leading ?");
assert!(!control(""));
assert!(!control("?"));
assert!(!control("?webgl"));
assert!(!control("?nocontrol&controls"), "only the whole entry name");
}
+70
View File
@@ -0,0 +1,70 @@
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
/// Saved files kept (the oldest go first).
pub const MAX_SAVED: usize = 16;
/// The last component of a `/` or `\` path.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
}
/// Files by name, newest last: saving a name again replaces it.
#[derive(Default)]
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
impl SavedFiles {
/// Keep `bytes` under the last component of `path`.
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
let name = file_name(path).to_string();
self.0.retain(|(n, _)| *n != name);
self.0.push((name, bytes.to_vec()));
let extra = self.0.len().saturating_sub(MAX_SAVED);
self.0.drain(..extra);
}
/// A kept file by its name, or by a path ending in it.
pub fn get(&self, name: &str) -> Option<&[u8]> {
let wanted = file_name(name);
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
}
/// `(name, size)` of each kept file, oldest first.
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_by_name_replaces_and_reads_back_by_path() {
let mut s = SavedFiles::default();
s.keep("exports/poster.png", b"one");
s.keep(r"C:\out\poster.png", b"two");
s.keep("notes.txt", b"three");
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("missing.png"), None);
}
#[test]
fn drops_the_oldest_past_the_cap() {
let mut s = SavedFiles::default();
for i in 0..MAX_SAVED + 3 {
s.keep(&format!("f{i}.png"), &[0]);
}
assert_eq!(s.list().count(), MAX_SAVED);
assert_eq!(s.get("f0.png"), None);
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
}
#[test]
fn a_trailing_separator_keeps_the_whole_name() {
let mut s = SavedFiles::default();
s.keep("odd/", b"x");
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
}
}
+21 -1
View File
@@ -47,6 +47,13 @@ pub fn start() {
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);
}
// `?control`: `window.figcraft`, the page's control channel (crate::api), opt-in.
if crate::query::control(&query()) {
crate::api::connect(cc.egui_ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
}),
)
@@ -69,9 +76,19 @@ struct WebShell {
}
impl eframe::App for WebShell {
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.control_queue.extend(crate::api::take_requests());
self.fig.ui(ui);
crate::api::poll_replies();
}
}
@@ -101,6 +118,9 @@ impl Services for WebServices {
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
if crate::api::capture(name, bytes) {
return true;
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
+8 -1
View File
@@ -21,7 +21,7 @@ figcraft-text = { workspace = true }
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
craft-window = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
@@ -33,8 +33,15 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
# accesskit already; this only makes it direct.
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
+1
View File
@@ -1,6 +1,7 @@
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
//! (no-op elsewhere).
fn main() {
println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)");
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
+39 -6
View File
@@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_ui_egui::ControlRequest;
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
@@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60);
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Why a request's transport wait ended without a control reply.
#[derive(Debug, PartialEq, Eq)]
enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
/// A commit that got the gate first has already queued its reply: consume that reply instead of
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
match rx.recv_timeout(timeout) {
Ok(reply) => Ok(reply),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
}
}
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
/// against a gate whose commit already happened.
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
@@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
}
}
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
@@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
@@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match rx.recv_timeout(timeout) {
match wait_for_reply(&rx, &gate, timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
}
}
Err(e) => {
+52 -2
View File
@@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
serde_json::from_str(&line).unwrap()
}
/// Build a `ui.inspect` control request with a throwaway reply channel.
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
#[test]
@@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
worker.join().unwrap().unwrap();
}
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
/// timeout branch consumes that reply and leaves the state `Replied`.
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
let path = dir.join("shot.png");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel();
// The UI's final commit gets the gate first: the file and reply are both committed before the
// transport's timeout branch can look at the gate.
let committed = gate.commit(|| {
std::fs::write(&path, b"png").unwrap();
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
});
assert!(committed.is_some());
assert!(path.is_file());
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
assert_eq!(reply.id, json!(7));
assert!(reply.result.is_ok());
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
assert!(path.is_file(), "a winning commit keeps its file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
/// cancelled.
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel::<ControlReply>();
drop(tx);
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
@@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
worker.join().unwrap().unwrap();
}
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
/// and answered, and a window that finally draws discards its expired command before
/// dispatch.
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
@@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
r.state.cancel_if_open();
r
}));
let (mut client, server) = pair();
+41 -15
View File
@@ -15,6 +15,11 @@
#![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")]
// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit
// reports a removed UI Automation node. Catch build configurations that drop the workaround.
#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))]
compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive");
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
@@ -62,6 +67,9 @@ struct App {
}
impl eframe::App for App {
/// Native eframe logic callback: store the first context, drain control requests, run
/// guarded background work, poll macOS open events, handle close requests and title
/// changes, and schedule recovery repaints on the app clock.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
{
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -71,7 +79,9 @@ impl eframe::App for App {
}
let title = with_app(|fig| {
control_server::drain_queue(&self.queue, &mut fig.control_queue);
figcraft_ui_egui::control::drain_hidden(fig, ctx);
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
// It runs on logic-only callbacks too, so it never reads UI input.
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
#[cfg(target_os = "macos")]
apple_events::poll(&self.apple_events, fig, ctx);
// Closing the window asks about unsaved changes (and keeps the app running while other
@@ -86,8 +96,14 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
let now = ctx.input(|i| i.time);
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
// Recovery deadlines use the same monotonic clock as the app's background work: a
// logic-only tick leaves `ctx.input(i.time)` stale.
if let Some(wait) = with_app(|fig| {
let now = fig.now_secs();
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
})
.flatten()
{
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
@@ -117,6 +133,11 @@ impl eframe::App for App {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
@@ -149,21 +170,23 @@ fn main() -> eframe::Result {
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
let fonts_slot = ctx_slot.clone();
// Wakes the window (once it is there) from another thread.
let wake = |slot: &Arc<Mutex<Option<egui::Context>>>| {
let slot = slot.clone();
move || {
if let Some(c) = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}
};
let fonts_wake = wake(&ctx_slot);
std::thread::spawn(move || {
figcraft_text::scan_system_fonts();
if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
fonts_wake();
});
let mut session = Session::empty();
// Live sessions: the network threads wake the window when something arrives.
let wake_slot = ctx_slot.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {
if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}))));
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(wake(&ctx_slot)))));
for f in &args.files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
log::error!("{f}: {e}");
@@ -175,6 +198,7 @@ fn main() -> eframe::Result {
// The preferences first: they hold the online files a recovered document may be one of.
let recovery = recovery::Shared::default();
let svc = services::Desktop::load(recovery.clone());
svc.follow_system_dark(wake(&ctx_slot));
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
@@ -199,6 +223,7 @@ 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.set_startup_update_check(true);
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
@@ -244,14 +269,15 @@ fn main() -> eframe::Result {
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
Box::new(move |cc| {
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
Ok(Box::new(App {
queue,
ctx_slot,
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
apple_events: apple_events.connect(&cc.egui_ctx),
}))
}),
);
+99 -3
View File
@@ -2,7 +2,7 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use figcraft_engine::{Prefs, Session};
@@ -11,11 +11,14 @@ use serde_json::{Value, json};
pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
egui_ctx: Option<egui::Context>,
/// Where the preferences live ([`config_dir`]).
dir: Option<PathBuf>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
/// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light.
dark: Arc<AtomicU8>,
/// The system's interface language (read at startup).
language: Option<String>,
/// Recovery copies of unsaved files (written at once after a panic).
@@ -56,16 +59,34 @@ impl Desktop {
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN));
Self {
clipboard: arboard::Clipboard::new().ok(),
egui_ctx: None,
dir,
prefs,
reduce_motion,
dark,
language: system_language(),
recovery,
last_folder: Arc::default(),
}
}
/// winit reports no system theme on Linux and the BSDs: there the freedesktop colour scheme
/// answers instead, read once and then watched in the background, calling `wake` when it
/// changes (an idle window draws no frames). Elsewhere this does nothing.
pub fn follow_system_dark(&self, wake: impl Fn() + Send + 'static) {
#[cfg(all(unix, not(target_os = "macos")))]
if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({
let dark = self.dark.clone();
move || watch_system_dark(&dark, &wake)
}) {
log::info!("colour scheme: can't start the watcher: {e}");
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
let _ = wake;
}
}
/// The system's preferred interface languages, most preferred first, comma-separated
@@ -146,6 +167,71 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light.
const DARK_UNKNOWN: u8 = 0;
const DARK_DARK: u8 = 1;
const DARK_LIGHT: u8 = 2;
/// The desktop's colour scheme through the freedesktop settings portal
/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light, 0 no preference, which is
/// light), read once and then watched for changes, each one calling `wake`. Without a session bus
/// or a portal the scheme stays unknown and the UI keeps its light fallback.
#[cfg(all(unix, not(target_os = "macos")))]
fn watch_system_dark(dark: &AtomicU8, wake: &dyn Fn()) {
use zbus::zvariant::Value;
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const APPEARANCE: &str = "org.freedesktop.appearance";
// `Read` (portals before 1.17, which lack `ReadOne`) wraps the value in one more variant.
let scheme = |v: &Value| match v {
Value::U32(x) => *x,
Value::Value(inner) => match **inner {
Value::U32(x) => x,
_ => 0,
},
_ => 0,
};
let store = |v: u32| {
let new = match v {
1 => DARK_DARK,
2 => DARK_LIGHT,
_ => DARK_UNKNOWN,
};
if dark.swap(new, Ordering::Relaxed) != new {
wake();
}
};
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else {
log::info!("colour scheme: no session bus: the system theme isn't followed");
return;
};
let read = |method: &str| -> zbus::Result<u32> {
let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), method, &(APPEARANCE, "color-scheme"))?;
let body = reply.body();
Ok(scheme(&body.deserialize::<Value>()?))
};
match read("ReadOne").or_else(|_| read("Read")) {
Ok(v) => store(v),
Err(e) => log::info!("colour scheme: can't read the system setting: {e}"),
}
let watch = || -> zbus::Result<()> {
let rule =
zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build();
for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? {
let msg = msg?;
let body = msg.body();
let Ok((namespace, key, value)) = body.deserialize::<(String, String, Value<'_>)>() else { continue };
if namespace == APPEARANCE && key == "color-scheme" {
store(scheme(&value));
}
}
Ok(())
};
if let Err(e) = watch() {
log::info!("colour scheme: can't watch the system setting: {e}");
}
}
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
fn reveal(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
@@ -325,10 +411,13 @@ impl Services for Desktop {
remembering(self.last_folder.clone(), pick, true)
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
let _ = c.set_text(s.to_string());
if let Some(ctx) = &self.egui_ctx {
ctx.copy_text(s.to_string());
}
}
fn set_egui_context(&mut self, ctx: &egui::Context) {
self.egui_ctx = Some(ctx.clone());
}
fn clipboard_get_text(&mut self) -> Option<String> {
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
}
@@ -372,6 +461,13 @@ impl Services for Desktop {
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
fn system_dark(&self) -> Option<bool> {
match self.dark.load(Ordering::Relaxed) {
DARK_DARK => Some(true),
DARK_LIGHT => Some(false),
_ => None,
}
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
+5 -11
View File
@@ -2,6 +2,7 @@
//! report it, instead of the app quitting silently (#261).
use figcraft_ui_egui::dialogs::window_failed as message;
use figcraft_ui_egui::i18n::tr;
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.";
@@ -11,13 +12,6 @@ const DRIVER_HINT: &str = if cfg!(windows) {
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
};
/// Other startup tests can change the process-wide language while this message is built.
fn contains_hint(text: &str, key: &str) -> bool {
use figcraft_ui_egui::i18n::{Text, languages};
text.contains(key)
|| languages().iter().flat_map(|lang| lang.ui).any(|(k, value)| *k == key && matches!(value, Text::Plain(hint) if text.contains(hint)))
}
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
let text = message(ERROR, false);
@@ -28,9 +22,9 @@ fn the_message_carries_the_error_and_where_to_report_it() {
#[test]
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
let text = message(ERROR, false);
assert!(contains_hint(&text, DRIVER_HINT), "{text}");
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!contains_hint(&text, BACKEND_HINT), "{text}");
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
}
#[cfg(windows)]
@@ -43,6 +37,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() {
#[test]
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
let text = message(ERROR, true);
assert!(contains_hint(&text, BACKEND_HINT), "{text}");
assert!(!contains_hint(&text, DRIVER_HINT), "the chosen backend is the likelier cause: {text}");
assert!(text.contains(tr(BACKEND_HINT)), "{text}");
assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}");
}
+3
View File
@@ -4,3 +4,6 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
+9
View File
@@ -72,6 +72,9 @@ sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
[people.mojtabajafarzadeh]
real_name = "Mojtaba Jafarzadeh"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
@@ -80,3 +83,9 @@ sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.leandrodslv]
sync_github_name = true
[people.mateusvillain]
sync_github_name = true
+4 -1
View File
@@ -106,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
assert!(host.live().peers.is_empty());
// A typing mistake is caught before anything connects.
let mut typo: Vec<char> = local.code().chars().collect();
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
// The last symbol contains only checksum bits. Changing payload bits can collide with
// the eight-bit checksum, so it does not guarantee a malformed code.
let last = typo.last_mut().unwrap();
*last = if *last == 'A' { 'B' } else { 'A' };
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
assert!(e.contains("typing mistake"), "{e}");
// A view-only code doesn't exist here, and says where it does.
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
use super::limits::Admission;
use super::*;
/// An admin request's reply, and what the hub must do about connections it affects.
#[derive(Clone, Debug, PartialEq)]
pub struct Handled {
pub reply: AdminReply,
/// A link was revoked: `(file, link)`; whoever joined with it is disconnected.
pub revoked: Option<(String, String)>,
/// A file was removed: whoever is in it is disconnected and it is closed without saving.
pub removed: Option<String>,
/// An address was blocked ([`ip_key`]): its connections are dropped.
pub blocked: Option<IpAddr>,
}
impl From<AdminReply> for Handled {
fn from(reply: AdminReply) -> Self {
Self { reply, revoked: None, removed: None, blocked: None }
}
}
/// What everyone is told when a hub stops.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Closing {
/// The session is over (the app stopped hosting): final.
Ended,
/// The server is restarting: clients reconnect until it is back.
Restarting,
}
/// What the hub knows of an open file (for listings).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OpenFile {
/// People connected to it.
pub people: u32,
/// Its latest sequence number.
pub seq: u64,
}
/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it
/// from its own thread only.
pub trait Backend: Send + 'static {
/// A hello for `file` with `token`: what it may do, or why it may not join (the reason is
/// shown to the person joining; never echo the token).
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String>;
/// The session for a file that isn't open (after [`admit`](Self::admit) let someone in).
fn open(&mut self, file: &str) -> Result<Server, String>;
/// The hub handled a message for an open file: keep what changed (its server's journal).
fn changed(&mut self, _file: &str, _server: &mut Server) {}
/// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers
/// after this. The files whose changes couldn't be made durable: nobody hears of them, the
/// files are closed (the backend forgets their open state) and everyone in them reconnects.
fn flush(&mut self) -> Vec<String> {
Vec::new()
}
/// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and
/// the batch is refused.
fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> {
Ok(())
}
/// Everyone left `file`: save it. True closes it (the next hello opens it again).
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
false
}
/// The hub is stopping: save the open files.
fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {}
/// A managing connection's key: whether it may manage files.
fn manage(&mut self, _key: &str) -> Result<(), String> {
Err("this session is hosted by an app: it has no files to manage".into())
}
/// A request on a managing connection; `open` tells about a file that is open.
fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
}
/// A request on a library connection to `file`, opened with a link of `access` (checked by
/// [`admit`](Self::admit)).
fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply {
LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() }
}
/// A request on a plugin directory connection; `publisher`: it was opened with the right key
/// (checked by [`manage`](Self::manage)).
fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply {
PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() }
}
/// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the
/// client).
fn connect(&mut self, _ip: IpAddr) -> Result<(), String> {
Ok(())
}
/// A message from a connection from `ip`: false disconnects it (that address sends too much).
fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool {
true
}
/// What to tell a client that reads it about the server's hosting, after its welcome to
/// `file` (`None`: nothing).
fn hosting(&mut self, _file: &str) -> Option<HostInfo> {
None
}
/// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty:
/// what the server says about its hosting, or why the connection is refused.
fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result<HostInfo, String> {
Err("this session is hosted by an app: it doesn't keep files for anyone".into())
}
/// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it
/// was opened with.
fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
}
/// Someone in `file` (joined with `link`, from `ip`) reports it to the operator.
fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> {
Err("this session is hosted by an app: tell the person hosting it".into())
}
/// A join code typed by someone at `ip`: the file it opens and that link's token, or why not
/// (shown to them; wrong codes are counted against the address).
fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> {
Err("this session is hosted by an app: join it with its own code or invite link".into())
}
/// What everyone is told when the hub stops.
fn closing(&self) -> Closing;
}
+404
View File
@@ -0,0 +1,404 @@
use super::hosting::{Open, spawn};
use super::limits::{PerAddress, ip_key};
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
use super::*;
/// How long a closing connection waits for the peer to hang up.
const LINGER: Duration = Duration::from_secs(2);
/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst;
/// beyond that they are dropped.
pub const SIGNAL_RATE: f64 = 20.0;
pub const SIGNAL_BURST: f64 = 40.0;
/// Longest request line an uptime check may have.
const MAX_CHECK_LINE: usize = 2048;
/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed.
/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket
/// handshake goes on untouched.
fn health_check(stream: &mut TcpStream, until: Instant) -> bool {
let mut buf = [0u8; MAX_CHECK_LINE];
let line = loop {
let n = match stream.peek(&mut buf) {
Ok(0) | Err(_) => return false,
Ok(n) => n,
};
let seen = buf.get(..n).unwrap_or_default();
if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) {
return false;
}
if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") {
break seen.get(..end).unwrap_or_default().to_vec();
}
if n == buf.len() || Instant::now() >= until {
return false;
}
// The rest of the line is on its way (it nearly always arrives in one piece).
std::thread::sleep(Duration::from_millis(10));
};
let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default();
let path = path.split(|b| *b == b'?').next().unwrap_or_default();
if !path.ends_with(b"/healthz") {
return false;
}
// Read the request (its headers end with a blank line), so closing doesn't reset it.
let mut req = Vec::new();
let mut chunk = [0u8; 1024];
while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 {
match stream.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()),
}
}
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n");
let _ = stream.shutdown(std::net::Shutdown::Write);
true
}
/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the
/// proxy added; any before it are whatever the client sent).
fn forwarded_for(req: &Request) -> Option<IpAddr> {
let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?;
let entry = last.rsplit(',').next()?.trim();
entry.parse::<IpAddr>().ok().or_else(|| entry.parse::<SocketAddr>().ok().map(|a| a.ip()))
}
/// What every connection shares: the count per address, the bytes queued for all of them and the
/// bytes in flight to the hub.
#[derive(Clone)]
pub(super) struct Shared {
pub(super) per_address: PerAddress,
pub(super) queued: Arc<AtomicUsize>,
pub(super) in_flight: InFlight,
pub(super) blocks: Blocks,
/// Bytes being read into messages, all connections together, and who is reading them.
pub(super) reading: Arc<AtomicUsize>,
pub(super) readers: Readers,
}
pub(super) fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits, shared: Shared) {
let mut next = 0u64;
for stream in listener.incoming() {
if stop.load(Ordering::SeqCst) {
break;
}
let stream = match stream {
Ok(s) => s,
Err(e) => {
// Out of sockets or similar: don't spin.
log::warn!("live session: accept failed: {e}");
std::thread::sleep(Duration::from_millis(50));
continue;
}
};
if open.load(Ordering::Relaxed) >= limits.max_connections {
log::info!("live session: too many connections; refusing one");
continue;
}
open.fetch_add(1, Ordering::Relaxed);
let guard = Open(open.clone());
next += 1;
let (conn, hub, shared) = (next, hub.clone(), shared.clone());
if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) {
log::warn!("live session: can't start a connection thread: {e}");
}
}
}
/// A close reason that fits a WebSocket close frame (123 bytes).
pub(super) fn reason(s: &str) -> String {
let mut end = s.len().min(123);
while !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or_default().to_string()
}
pub(super) fn encode(msg: &ServerMsg) -> Option<Bytes> {
match message::encode(msg) {
Ok(b) => Some(Bytes::from(b)),
Err(e) => {
log::warn!("live session: can't encode a message: {e}");
None
}
}
}
/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps).
pub(super) fn refuse(out: &Line, why: &str) {
refused(out, why);
}
/// [`refuse`], and whether the close was queued (it isn't when the queue is full).
pub(super) fn refused(out: &Line, why: &str) -> bool {
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
let _ = out.tx.try_send(Out::Bytes(b, None));
}
out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok()
}
/// What the server tells a client when it is too busy to take more from it.
const BUSY: &str = "the server is busy: try again in a moment";
/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize, deadline: Option<Instant>) -> Result<Option<(ClientMsg, usize)>, String> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => {
// Whole now: it counts as in flight from here, no longer as being read.
ws.get_mut().read_done();
message::decode::<ClientMsg>(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string())
}
Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()),
// Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and
// a message trickled in is cut off by it (`Duplex::read_until`).
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_))
if deadline.is_some_and(|d| Instant::now() >= d) =>
{
Err("no hello in time".into())
}
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
Ok(Message::Close(_)) => Ok(None),
Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)),
// The reading budget's refusal (see `Meter`).
Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()),
Err(_) => Ok(None),
};
}
}
/// Why a message over `max` bytes is refused.
fn too_large(max: usize) -> String {
match max >> 20 {
0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10),
mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"),
}
}
/// One connection's reader (the writer runs on its own thread).
fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, open: Open, shared: &Shared) {
let _ = stream.set_nodelay(true);
let hello_deadline = Instant::now() + limits.hello_timeout;
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
if health_check(&mut stream, Instant::now() + limits.hello_timeout) {
return;
}
let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return };
let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer);
let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return };
// The handshake and the hello must be in by the deadline, however slowly they trickle in.
rd.read_until(Some(hello_deadline));
let cfg = config(limits.max_client_message);
let mut forwarded = None;
// The handshake callback's type is tungstenite's (its error is an HTTP response).
#[allow(clippy::result_large_err)]
let read_client = |req: &Request, resp: Response| -> Result<Response, ErrorResponse> {
if proxied {
forwarded = forwarded_for(req);
}
Ok(resp)
};
let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) {
Ok(ws) => ws,
Err(e) => {
log::debug!("live session: handshake failed: {e}");
shutdown(&sock);
return;
}
};
if proxied && forwarded.is_none() {
log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For");
}
let ip = ip_key(forwarded.unwrap_or(peer));
let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections);
let wide = wide_block(ip);
let block = shared.blocks.get(wide);
ws.get_mut().meter(Meter::new(
conn,
&sock,
&shared.readers,
block.reading.clone(),
shared.reading.clone(),
limits.max_reading_block,
limits.max_reading_total,
));
let (tx, rx) = sync_channel::<Out>(limits.queue.max(4));
let out = Line {
tx,
queued: Arc::default(),
block: block.queued.clone(),
all: shared.queued.clone(),
max: limits.max_queued_bytes,
max_block: limits.max_queued_block,
max_all: limits.max_queued_total,
};
let writer =
match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) {
Ok(Ok(t)) => t,
_ => {
shutdown(&sock);
return;
}
};
let end = |why: Option<&str>| {
if let Some(why) = why {
refuse(&out, why);
} else {
let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new()));
}
};
if counted.is_none() {
log::info!("event=refused ip={ip} reason=connections_per_address");
end(Some("too many connections from your address: close some and try again"));
} else {
let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline));
// In time or not, the hello's deadline is over: what's read next has timeouts of its own.
ws.get_mut().read_until(None);
match hello {
Ok(Some((
first @ (ClientMsg::Hello(_)
| ClientMsg::Manage(_)
| ClientMsg::OpenLibrary(_)
| ClientMsg::OpenPlugins(_)
| ClientMsg::OpenHosting(_)
| ClientMsg::Resolve(_)),
bytes,
))) => {
// Sessions and the operator may use the last connections; one-shot requests
// (hosting, libraries, browsing plugins) leave them free.
let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_));
// A publish uploads a whole file; a large one on a slow link takes the server a
// while to receive and answer. The manage/hosting connections that carry uploads
// wait the longer request-reply timeout, not the short request idle.
let idle = match first {
ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout,
ClientMsg::Hello(_) => limits.idle_timeout,
_ => limits.request_idle,
};
let max = match &first {
ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request,
_ => limits.max_client_message,
};
let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions);
// (In flight is only taken when the connection goes on: the hub gives it back.)
if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) {
end(Some(BUSY));
} else {
let (admitted, verdict) = sync_channel(1);
let opened =
sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok());
// Nothing more is read until the hub took the connection in (its token checked):
// only then may it send messages of the full size.
if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
ws.set_config(|c| *c = config(max));
read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end);
let _ = hub.send(HubIn::Gone { conn });
}
}
}
Ok(Some(_)) => end(Some("say hello first")),
Ok(None) => {}
Err(why) => end(Some(&why)),
}
}
drop(counted);
// The block's counters go with the last handle on them (the table forgets it then).
drop(block);
ws.get_mut().read_until(None);
linger(&mut ws);
drop(out);
// The writer closes the socket once it has sent what's queued (a refusal, a close).
let _ = writer.join();
shutdown(&sock);
}
fn read_loop(
ws: &mut WebSocket<Duplex>,
conn: u64,
hub: &Sender<HubIn>,
limits: NetLimits,
max: usize,
in_flight: &InFlight,
end: &dyn Fn(Option<&str>),
) {
let mut bucket = Bucket::new(limits.rate, limits.burst);
let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST);
loop {
match next_message(ws, max, None) {
Ok(Some((msg, bytes))) => {
let now = Instant::now();
if !bucket.take(now) {
log::info!("live session: connection {conn} sends too much; disconnecting it");
return end(Some("too many messages"));
}
// Typing sends a chat signal per keystroke; more than this is dropped.
if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) {
log::debug!("live session: connection {conn} sends signals too fast; dropping one");
continue;
}
if !in_flight.take(bytes, limits.max_in_flight) {
log::info!("live session: the hub is behind; dropping connection {conn}");
return end(Some(BUSY));
}
if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() {
return;
}
}
Ok(None) => return end(None),
Err(why) => return end(Some(&why)),
}
}
}
fn write_loop(mut ws: WebSocket<Duplex>, rx: &Receiver<Out>, sock: &TcpStream) {
let mut close = Some((CloseCode::Normal, String::new()));
while let Ok(o) = rx.recv() {
match o {
Out::Bytes(b, _charge) => {
if ws.send(Message::Binary(b)).is_err() {
close = None;
break;
}
}
Out::Close(code, why) => {
close = Some((code, why));
break;
}
}
}
if let Some((code, why)) = close {
// Close politely: the close frame, then no more writing; the reader drains what the peer
// still sends until it hangs up (closing on unread data would reset the connection and
// could lose the close frame), or until the linger runs out.
let _ = ws.close(Some(CloseFrame { code, reason: why.into() }));
let _ = sock.shutdown(std::net::Shutdown::Write);
let until = Instant::now() + LINGER;
while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
if Instant::now() >= until {
break;
}
}
}
shutdown(sock);
}
/// Read and drop what the peer still sends after the close, until it hangs up (at most
/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader
/// is mid-frame and mustn't be asked for more.
fn linger(ws: &mut WebSocket<Duplex>) {
let until = Instant::now() + LINGER;
let mut buf = [0u8; 4096];
while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() {
match ws.get_mut().read(&mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {}
Err(_) => break,
}
}
}
+201
View File
@@ -0,0 +1,201 @@
use super::backend::Backend;
use super::conn::{Shared, accept};
use super::limits::{PerAddress, loopback};
use super::queue::{Blocks, HubIn, InFlight};
use super::*;
/// The person hosting an app session: in the file's session without a socket.
pub(crate) struct Local {
pub file: String,
pub server: Server,
/// Their client id (they joined `server` already).
pub id: u32,
pub inbox: Inbox,
}
pub(super) fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result<JoinHandle<()>> {
std::thread::Builder::new().name(name.to_string()).spawn(f)
}
/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters.
pub(crate) fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect()
}
/// A running hub: its listener, connections and hub thread. Dropping it stops it.
pub struct Hosting {
addr: SocketAddr,
hub: Sender<HubIn>,
stop: Arc<AtomicBool>,
open: Arc<AtomicUsize>,
/// Bytes queued for every connection together, and in flight to the hub.
queued: Arc<AtomicUsize>,
in_flight: InFlight,
blocks: Blocks,
reading: Arc<AtomicUsize>,
hub_thread: Option<JoinHandle<()>>,
accept_thread: Option<JoinHandle<()>>,
}
impl Hosting {
/// Serve `backend`'s files on `listener`.
pub fn serve<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result<Hosting> {
Self::start(listener, backend, limits, None)
}
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
let addr = listener.local_addr()?;
let in_flight = InFlight(Arc::default());
let mut hub = Hub {
backend,
rooms: HashMap::new(),
conns: HashMap::new(),
local: None,
outbox: Vec::new(),
in_flight: in_flight.clone(),
barred: HashMap::new(),
};
if let Some(l) = local {
let mut room = Room::new(l.server);
room.local = Some((l.id, l.inbox));
hub.rooms.insert(l.file.clone(), room);
hub.local = Some(l.file);
}
let (hub_tx, hub_rx) = channel();
let stop = Arc::new(AtomicBool::new(false));
let open = Arc::new(AtomicUsize::new(0));
let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?;
let shared = Shared {
per_address: PerAddress::default(),
queued: Arc::default(),
in_flight: in_flight.clone(),
blocks: Blocks::default(),
reading: Arc::default(),
readers: Readers::default(),
};
let queued = shared.queued.clone();
let blocks = shared.blocks.clone();
let reading = shared.reading.clone();
let accept_thread = {
let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone());
spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared))
};
let accept_thread = match accept_thread {
Ok(t) => t,
Err(e) => {
let _ = hub_tx.send(HubIn::Stop);
let _ = hub_thread.join();
return Err(e);
}
};
log::info!("live session: listening on {addr}");
Ok(Hosting {
addr,
hub: hub_tx,
stop,
open,
queued,
in_flight,
blocks,
reading,
hub_thread: Some(hub_thread),
accept_thread: Some(accept_thread),
})
}
/// Bytes waiting to be sent to clients, all connections together.
pub fn queued_bytes(&self) -> usize {
self.queued.load(Ordering::Relaxed)
}
/// Blocks of addresses the server keeps counters for (those of open connections, and any
/// whose last connection just ended, until the next connection prunes them).
pub fn blocks_remembered(&self) -> usize {
self.blocks.len()
}
/// Bytes being read into messages not whole yet, all connections together.
pub fn reading_bytes(&self) -> usize {
self.reading.load(Ordering::Relaxed)
}
/// Bytes of messages read and not handled by the hub yet.
pub fn in_flight_bytes(&self) -> usize {
self.in_flight.0.load(Ordering::Relaxed)
}
/// Where it listens.
pub fn addr(&self) -> SocketAddr {
self.addr
}
/// Open connections (joined or not).
pub fn connections(&self) -> usize {
self.open.load(Ordering::Relaxed)
}
/// The hosting person's presence (app sessions).
pub(crate) fn presence(&self, p: Presence) {
let _ = self.hub.send(HubIn::Local(p));
}
/// A message from the hosting person's sync client (their edits).
pub(crate) fn send(&self, msg: ClientMsg) {
let _ = self.hub.send(HubIn::LocalMsg(msg));
}
/// The hosting person removes client `id` from their file (app sessions; see
/// [`HubIn::Remove`]).
pub(crate) fn remove(&self, id: u32) {
let _ = self.hub.send(HubIn::Remove(id));
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
let _ = self.hub.send(HubIn::Cut);
}
/// Stop: the backend saves what's open, everyone is told and disconnected, the port is
/// released.
pub fn stop(&mut self) {
if self.stop.swap(true, Ordering::SeqCst) {
return;
}
let _ = self.hub.send(HubIn::Stop);
if let Some(t) = self.hub_thread.take() {
let _ = t.join();
}
// The accept loop checks the flag after each connection: wake it with one.
let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr };
let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok();
if let Some(t) = self.accept_thread.take() {
if woke {
let _ = t.join();
} else {
log::warn!("live session: couldn't wake the accept loop; it ends with the next connection");
}
}
// Readers end once their writers have closed the sockets.
let deadline = Instant::now() + Duration::from_secs(2);
while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
log::info!("live session: stopped listening on {}", self.addr);
}
}
impl Drop for Hosting {
fn drop(&mut self) {
self.stop();
}
}
/// Counts an open connection until dropped.
pub(super) struct Open(pub(super) Arc<AtomicUsize>);
impl Drop for Open {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
+99
View File
@@ -0,0 +1,99 @@
use super::*;
/// What a link lets a hello do.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Admission {
pub access: Access,
/// The link's id, when the backend has several per file (revoking it disconnects whoever
/// joined with it).
pub link: Option<String>,
}
/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address
/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses).
pub fn ip_key(ip: IpAddr) -> IpAddr {
match ip.to_canonical() {
IpAddr::V6(v6) => {
let [a, b, c, d, ..] = v6.segments();
IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0))
}
v4 => v4,
}
}
/// The loopback address of `addr`'s family.
pub(super) fn loopback(addr: SocketAddr) -> std::net::IpAddr {
if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() }
}
/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a
/// /48 are different blocks).
pub type Block = (IpAddr, u8);
/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each
/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48
/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times.
pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> {
let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] };
let [a, b, c, d, ..] = v6.segments();
vec![
((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1),
((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2),
((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4),
]
}
/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address
/// leaves when its last one closes, so the table never holds more than the open connections.
#[derive(Clone, Default)]
pub(super) struct PerAddress(pub(super) Arc<Mutex<HashMap<Block, usize>>>);
/// One connection counted for a block until dropped.
pub(super) struct Counted {
table: PerAddress,
ip: Block,
}
impl PerAddress {
/// Count a connection from block `ip`, unless it has `cap` already (0: no cap).
fn enter(&self, ip: Block, cap: usize) -> Option<Counted> {
let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
let n = t.entry(ip).or_insert(0);
if cap > 0 && *n >= cap {
if *n == 0 {
t.remove(&ip);
}
return None;
}
*n += 1;
Some(Counted { table: self.clone(), ip })
}
/// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider
/// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of
/// `all` connections, so no block takes every connection.
pub(super) fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option<Vec<Counted>> {
let mut held = Vec::new();
for ((addr, prefix), times) in ip_blocks(ip) {
let block_cap = match prefix {
56 => cap.saturating_mul(times as usize).min(all / 16).max(1),
48 => cap.saturating_mul(times as usize).min(all / 8).max(1),
_ => cap,
};
held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?);
}
Some(held)
}
}
impl Drop for Counted {
fn drop(&mut self) {
let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(n) = t.get_mut(&self.ip) {
*n = n.saturating_sub(1);
if *n == 0 {
t.remove(&self.ip);
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
use super::limits::ip_blocks;
use super::*;
/// A message for one connection's writer: bytes (with what they count against, released when
/// they are sent or dropped), or the close.
pub(crate) enum Out {
Bytes(Bytes, Option<Charge>),
Close(CloseCode, String),
}
/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted
/// once against a shared counter (the server's, or a block's) until the last copy is sent or
/// dropped.
pub(super) struct Held {
n: usize,
counter: Arc<AtomicUsize>,
}
impl Held {
fn new(n: usize, counter: &Arc<AtomicUsize>) -> Arc<Held> {
counter.fetch_add(n, Ordering::Relaxed);
Arc::new(Held { n, counter: counter.clone() })
}
}
impl Drop for Held {
fn drop(&mut self) {
self.counter.fetch_sub(self.n, Ordering::Relaxed);
}
}
/// What one message counts against, made by its first recipient and shared by the others: the
/// server's budget once, and each recipient block's once (twenty people behind one office's
/// address cost their block one copy of a broadcast, not twenty).
#[derive(Default)]
pub(crate) struct Charges {
pub(super) all: Option<Arc<Held>>,
pub(super) blocks: Vec<(usize, Arc<Held>)>,
/// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each
/// connection's cap, and a whole office joining at once must fit).
pub(crate) skip_blocks: bool,
}
/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through
/// [`Held`], once against the server's budget and once against its block's).
pub(super) struct Charge {
n: usize,
mine: Arc<AtomicUsize>,
_all: Arc<Held>,
_block: Option<Arc<Held>>,
}
impl Drop for Charge {
fn drop(&mut self) {
self.mine.fetch_sub(self.n, Ordering::Relaxed);
}
}
/// Why a message couldn't be queued.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Push {
/// The connection's or its block's queue is full: it can't keep up.
Full,
/// The server's budget for queued bytes is used up.
Everyone,
/// The connection is gone.
Gone,
}
/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its
/// block of addresses', and the server's for everyone), so a client that doesn't read can't make
/// the server hold more than that for it.
#[derive(Clone)]
pub(crate) struct Line {
pub(crate) tx: SyncSender<Out>,
pub(crate) queued: Arc<AtomicUsize>,
pub(crate) block: Arc<AtomicUsize>,
pub(crate) all: Arc<AtomicUsize>,
pub(crate) max: usize,
pub(crate) max_block: usize,
pub(crate) max_all: usize,
}
impl Line {
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
/// gets out). `charges` is what the message counts against, shared by its recipients;
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
/// to make room).
pub(crate) fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> {
let n = b.len();
let mine = self.queued.load(Ordering::Relaxed);
if mine > 0 && mine.saturating_add(n) > self.max {
return Err(Push::Full);
}
let key = Arc::as_ptr(&self.block) as usize;
let block = if charges.skip_blocks {
None
} else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) {
Some(h.clone())
} else {
let used = self.block.load(Ordering::Relaxed);
if used > 0 && used.saturating_add(n) > self.max_block {
return Err(Push::Full);
}
let h = Held::new(n, &self.block);
charges.blocks.push((key, h.clone()));
Some(h)
};
let all = match &charges.all {
Some(h) => h.clone(),
None => {
if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all {
return Err(Push::Everyone);
}
charges.all.insert(Held::new(n, &self.all)).clone()
}
};
self.queued.fetch_add(n, Ordering::Relaxed);
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
TrySendError::Full(_) => Push::Full,
TrySendError::Disconnected(_) => Push::Gone,
})
}
}
/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is
/// having read, shared by its connections.
#[derive(Clone, Default)]
pub(crate) struct BlockUse {
pub(super) queued: Arc<AtomicUsize>,
pub(super) reading: Arc<AtomicUsize>,
}
/// The blocks with open connections; a block leaves when its last connection ends.
#[derive(Clone, Default)]
pub(crate) struct Blocks(pub(crate) Arc<Mutex<HashMap<IpAddr, BlockUse>>>);
impl Blocks {
/// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters,
/// whose connections' queues and readers are all gone) are forgotten first, so the table
/// never holds more than the blocks of open connections.
pub(crate) fn get(&self, key: IpAddr) -> BlockUse {
let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1);
t.entry(key).or_default().clone()
}
/// Blocks remembered.
pub(crate) fn len(&self) -> usize {
self.0.lock().unwrap_or_else(PoisonError::into_inner).len()
}
}
/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an
/// IPv6 address's /48.
pub fn wide_block(key: IpAddr) -> IpAddr {
ip_blocks(key).last().map_or(key, |(b, _)| b.0)
}
/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader
/// drops its connection instead of piling more on a busy hub.
#[derive(Clone)]
pub(crate) struct InFlight(pub(crate) Arc<AtomicUsize>);
impl InFlight {
pub(super) fn take(&self, n: usize, max: usize) -> bool {
if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max {
self.0.fetch_sub(n, Ordering::Relaxed);
return false;
}
true
}
pub(super) fn give(&self, n: usize) {
self.0.fetch_sub(n, Ordering::Relaxed);
}
}
pub(super) enum HubIn {
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`,
/// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]).
Open {
conn: u64,
ip: IpAddr,
first: ClientMsg,
out: Line,
sock: TcpStream,
/// Its size (in flight until handled).
bytes: usize,
/// Told when the hub takes the connection in (dropped unsent when it doesn't).
admitted: SyncSender<()>,
},
Msg {
conn: u64,
msg: ClientMsg,
bytes: usize,
},
Gone {
conn: u64,
},
Local(Presence),
LocalMsg(ClientMsg),
/// The person hosting removes client `id` from their file (app sessions).
Remove(u32),
/// Cut every connection without a word (tests: a network failure).
#[cfg(test)]
Cut,
Stop,
}
+20
View File
@@ -24,6 +24,12 @@ pub struct NetLimits {
pub hello_timeout: Duration,
/// A connection with no traffic for this long is dead.
pub idle_timeout: Duration,
/// How long a one-shot request connection (hosting, a library, a plugin directory — and, most
/// importantly, publishing a file) waits for the server's reply before giving up. Publishing
/// uploads a whole file and a large one on a slow link takes the server a while to receive and
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
/// easily need several minutes).
pub request_reply_timeout: Duration,
/// A quiet client pings this often (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
@@ -68,6 +74,7 @@ impl Default for NetLimits {
burst: 400.0,
hello_timeout: Duration::from_secs(10),
idle_timeout: Duration::from_secs(45),
request_reply_timeout: Duration::from_secs(300),
keepalive: Duration::from_secs(15),
queue: 1024,
write_timeout: Duration::from_secs(10),
@@ -85,3 +92,16 @@ impl Default for NetLimits {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_reply_wait_is_longer_than_idle() {
let d = NetLimits::default();
// Publishing uploads a whole file; a slow link must not cut it off at the session idle
// timeout. Keep the reply wait comfortably above it.
assert!(d.request_reply_timeout > d.idle_timeout);
}
}
+5 -3
View File
@@ -196,13 +196,15 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits,
Ok(r)
}
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
/// server a while to keep).
/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload
/// takes the server a while to keep).
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
let (stream, _, _) = open(server, timeout, limits)?;
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
// A publish uploads a whole file; a large project on a slow link can take several minutes to
// receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`.
ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?;
Ok(ws)
}
+6
View File
@@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
}
/// Asserts two files are hosted and joined independently: each has its own port, session and
/// edits, presence stays in its file's session, and closing one file's session leaves the
/// other live.
#[test]
fn two_files_are_hosted_and_joined_independently() {
let mut host = session("Hana");
@@ -126,7 +129,10 @@ fn two_files_are_hosted_and_joined_independently() {
// Each joined file opens in front when its welcome arrives, and the two joins race: the file
// whose welcome came last is in front.
assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front");
// Concurrent joins may complete in either order. Put B in front explicitly so the following
// assertions exercise A's background session independent of network timing.
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits");
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
let in_a = rect(&mut host);
host.execute("doc.switch", &json!({"index": 1})).unwrap();
+9 -2
View File
@@ -223,6 +223,10 @@ pub enum DocProp {
VariableOrder,
/// A design file or a board.
Kind,
/// A board's countdown (`null`: none).
Timer,
/// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s.
Vote,
}
/// A document-level entry, by id.
@@ -242,6 +246,8 @@ pub enum Entry {
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
Image(String),
/// A voter's ballot in a board's vote (value: the layer ids voted on).
Ballot(String),
}
/// One synced value a change writes: a node property, a document-level property or a
@@ -315,7 +321,7 @@ impl Entry {
Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)),
Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)),
Entry::Comment(id) => Some(space_of(*id)),
Entry::DevSnapshot(_) | Entry::Image(_) => None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
@@ -328,6 +334,7 @@ impl Entry {
Entry::Variable(id) => format!("variable {id}"),
Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"),
Entry::Image(hash) => format!("image {hash}"),
Entry::Ballot(voter) => format!("ballot of {voter}"),
}
}
@@ -338,7 +345,7 @@ impl Entry {
Entry::Comment(_) => Some(Family::Comments),
Entry::Collection(_) => Some(Family::Collections),
Entry::Variable(_) => Some(Family::Variables),
Entry::DevSnapshot(_) | Entry::Image(_) => None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
}
+10
View File
@@ -11,6 +11,16 @@ use crate::message::{
use crate::sim::Rng;
use crate::{Error, Replica, diff};
#[test]
fn encoding_into_a_prefix_keeps_the_wire_body() {
let msg = moved_layer();
let prefix = b"container header".to_vec();
let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap();
assert_eq!(&bytes[..prefix.len()], prefix);
assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap());
assert_eq!(decode::<ClientMsg>(&bytes[prefix.len()..], bytes.len()).unwrap(), msg);
}
/// A typical edit: one layer dragged (its transform).
fn moved_layer() -> ClientMsg {
let mut doc = Document::new("f");
+12 -1
View File
@@ -211,6 +211,8 @@ doc_scalars! {
AnnotationCategories => annotation_categories,
Libraries => libraries,
Kind => kind,
Timer => timer,
Vote => vote,
}
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
@@ -250,7 +252,8 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|k| Entry::Image(k.clone()),
image_json,
out,
)
)?;
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
}
/// Apply a document-level change. Returns whether it can change which pages are listed (the
@@ -286,6 +289,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
let img = image_of(value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
doc.votes.insert(k.clone(), value.read("ballot")?);
}
},
Change::Remove { entry } => match entry {
Entry::Style(k) => STYLES.remove(doc, k),
@@ -298,6 +304,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
Entry::Image(k) => {
doc.images.remove(k);
}
Entry::Ballot(k) => {
doc.votes.remove(k);
}
},
_ => return Err(Error::Invalid("not a document-level change".into())),
}
@@ -334,6 +343,7 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result<Option<Json>,
Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of),
Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())),
Entry::Image(k) => doc.images.get(k).map(image_json),
Entry::Ballot(k) => doc.votes.get(k).map(Json::of),
};
value.transpose()
}
@@ -347,6 +357,7 @@ pub(crate) fn has(doc: &Document, entry: &Entry) -> bool {
Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k),
Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k),
Entry::Image(k) => doc.images.contains_key(k),
Entry::Ballot(k) => doc.votes.contains_key(k),
}
}
+1
View File
@@ -92,6 +92,7 @@ impl fmt::Display for Key {
Entry::Variable(k) => write!(f, "variable/{k}"),
Entry::DevSnapshot(k) => write!(f, "devSnapshot/{k}"),
Entry::Image(k) => write!(f, "image/{k}"),
Entry::Ballot(k) => write!(f, "ballot/{k}"),
},
}
}
+7 -5
View File
@@ -739,11 +739,13 @@ pub fn utc_date(secs: u64) -> String {
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, Error> {
let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?;
let mut out = Vec::with_capacity(body.len() + 1);
out.push(WIRE_VERSION);
out.extend(body);
Ok(out)
encode_into(msg, Vec::new())
}
/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer.
pub fn encode_into<T: Serialize>(msg: &T, mut prefix: Vec<u8>) -> Result<Vec<u8>, Error> {
prefix.push(WIRE_VERSION);
postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string()))
}
/// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most
+1 -1
View File
@@ -3,7 +3,7 @@ name = "figcraft-color"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft colour and paint: RGBA colours, HSB/HSL, hex/CSS parsing, solid/gradient/image paints, blend modes"
description = "FigCraft colour and paint: RGBA colours, HSB/HSL/LCH/OKLCH, hex/CSS parsing, solid/gradient/image paints, blend modes"
[lints]
workspace = true
+2 -1
View File
@@ -1,4 +1,5 @@
//! HSB (HSV) and HSL, the models the colour picker offers besides RGB and hex.
//! HSB (HSV) and HSL, two of the models the colour picker offers besides RGB and hex (LCH and
//! OKLCH are in `lch.rs`).
use crate::Color;
use serde::{Deserialize, Serialize};
+164
View File
@@ -0,0 +1,164 @@
//! CIE LCH (CSS `lch()`, Lab with a D50 white) and OKLCH (CSS `oklch()`), the perceptual models
//! the colour picker offers. Colours are read as sRGB, like the hex, RGB, HSL and HSB fields.
//!
//! The matrices are the ones CSS Color 4 publishes (sRGB ↔ XYZ D65, Bradford D65 ↔ D50) and
//! Björn Ottosson's published OKLab matrices.
use crate::Color;
use crate::profile::{apply as mul, decode, encode};
use serde::{Deserialize, Serialize};
/// CIE LCH: lightness 0..100, chroma 0.. (about 0..150 for real colours), hue 0..360.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Lch {
pub l: f32,
pub c: f32,
pub h: f32,
}
/// OKLCH: lightness 0..1, chroma 0.. (about 0..0.4 for real colours), hue 0..360.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Oklch {
pub l: f32,
pub c: f32,
pub h: f32,
}
type V3 = [f64; 3];
fn linear_rgb(c: Color) -> V3 {
[decode(c.r), decode(c.g), decode(c.b)]
}
const SRGB_TO_XYZ: [V3; 3] = [
[0.412_390_799_265_959_34, 0.357_584_339_383_878, 0.180_480_788_401_834_3],
[0.212_639_005_871_510_27, 0.715_168_678_767_756, 0.072_192_315_360_733_71],
[0.019_330_818_715_591_82, 0.119_194_779_794_625_98, 0.950_532_152_249_660_7],
];
const XYZ_TO_SRGB: [V3; 3] = [
[3.240_969_941_904_522_6, -1.537_383_177_570_094, -0.498_610_760_293_003_4],
[-0.969_243_636_280_879_6, 1.875_967_501_507_720_2, 0.041_555_057_407_175_59],
[0.055_630_079_696_993_66, -0.203_976_958_888_976_52, 1.056_971_514_242_878_6],
];
const D65_TO_D50: [V3; 3] = [
[1.047_929_820_840_548_8, 0.022_946_793_341_019_088, -0.050_192_229_543_135_57],
[0.029_627_815_688_159_344, 0.990_434_484_573_249, -0.017_073_825_029_385_14],
[-0.009_243_058_152_591_178, 0.015_055_144_896_577_895, 0.751_874_289_958_000_8],
];
const D50_TO_D65: [V3; 3] = [
[0.955_473_452_704_218_2, -0.023_098_536_874_261_423, 0.063_259_308_661_021_7],
[-0.028_369_706_963_208_136, 1.009_995_458_005_822_6, 0.021_041_398_966_943_008],
[0.012_314_001_688_319_899, -0.020_507_696_433_477_912, 1.330_365_936_608_075_3],
];
const D50_WHITE: V3 = [0.3457 / 0.3585, 1.0, (1.0 - 0.3457 - 0.3585) / 0.3585];
const KAPPA: f64 = 24389.0 / 27.0;
const EPSILON: f64 = 216.0 / 24389.0;
const SRGB_TO_LMS: [V3; 3] = [
[0.412_221_470_8, 0.536_332_536_3, 0.051_445_992_9],
[0.211_903_498_2, 0.680_699_545_1, 0.107_396_956_6],
[0.088_302_461_9, 0.281_718_837_6, 0.629_978_700_5],
];
const LMS_TO_OKLAB: [V3; 3] = [
[0.210_454_255_3, 0.793_617_785_0, -0.004_072_046_8],
[1.977_998_495_1, -2.428_592_205_0, 0.450_593_709_9],
[0.025_904_037_1, 0.782_771_766_2, -0.808_675_766_0],
];
const OKLAB_TO_LMS: [V3; 3] =
[[1.0, 0.396_337_777_4, 0.215_803_757_3], [1.0, -0.105_561_345_8, -0.063_854_172_8], [1.0, -0.089_484_177_5, -1.291_485_548_0]];
const LMS_TO_SRGB: [V3; 3] = [
[4.076_741_662_1, -3.307_711_591_3, 0.230_969_929_2],
[-1.268_438_004_6, 2.609_757_401_1, -0.341_319_396_5],
[-0.004_196_086_3, -0.703_418_614_7, 1.707_614_701_0],
];
fn lab_from_color(c: Color) -> V3 {
let xyz = mul(&D65_TO_D50, mul(&SRGB_TO_XYZ, linear_rgb(c)));
let f = |v: f64| if v > EPSILON { v.cbrt() } else { (KAPPA * v + 16.0) / 116.0 };
let [fx, fy, fz] = [f(xyz[0] / D50_WHITE[0]), f(xyz[1] / D50_WHITE[1]), f(xyz[2] / D50_WHITE[2])];
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
fn linear_from_lab([l, a, b]: V3) -> V3 {
let fy = (l + 16.0) / 116.0;
let fx = fy + a / 500.0;
let fz = fy - b / 200.0;
let inv = |f: f64| if f.powi(3) > EPSILON { f.powi(3) } else { (116.0 * f - 16.0) / KAPPA };
let y = if l > KAPPA * EPSILON { fy.powi(3) } else { l / KAPPA };
let xyz = [inv(fx) * D50_WHITE[0], y * D50_WHITE[1], inv(fz) * D50_WHITE[2]];
mul(&XYZ_TO_SRGB, mul(&D50_TO_D65, xyz))
}
fn oklab_from_color(c: Color) -> V3 {
let lms = mul(&SRGB_TO_LMS, linear_rgb(c));
mul(&LMS_TO_OKLAB, [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()])
}
fn linear_from_oklab(lab: V3) -> V3 {
let lms = mul(&OKLAB_TO_LMS, lab);
mul(&LMS_TO_SRGB, [lms[0].powi(3), lms[1].powi(3), lms[2].powi(3)])
}
/// Lab → (lightness, chroma, hue); grey has hue 0.
fn polar([l, a, b]: V3, grey: f64) -> (f32, f32, f32) {
let c = a.hypot(b);
let h = if c < grey { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
(l as f32, c as f32, h as f32)
}
fn finite(v: f32) -> f64 {
if v.is_finite() { f64::from(v) } else { 0.0 }
}
fn in_gamut(rgb: V3) -> bool {
rgb.iter().all(|v| (-1e-6..=1.0 + 1e-6).contains(v))
}
/// The sRGB colour for a lightness, chroma and hue: out of gamut, the chroma comes down (a binary
/// search) until it fits, so lightness and hue stay what was asked for, as CSS gamut mapping does.
fn map_to_srgb(l: f64, c: f64, h: f64, to_linear_rgb: impl Fn(V3) -> V3) -> Color {
let (sin, cos) = h.to_radians().sin_cos();
let lab = |c: f64| [l, c * cos, c * sin];
let mut rgb = to_linear_rgb(lab(c));
if !in_gamut(rgb) {
let (mut lo, mut hi) = (0.0, c);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if in_gamut(to_linear_rgb(lab(mid))) {
lo = mid;
} else {
hi = mid;
}
}
rgb = to_linear_rgb(lab(lo));
}
Color::new(encode(rgb[0]), encode(rgb[1]), encode(rgb[2]), 1.0)
}
impl Lch {
pub fn from_color(c: Color) -> Self {
let (l, c, h) = polar(lab_from_color(c), 0.02);
Self { l, c, h }
}
/// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show.
pub fn to_color(self) -> Color {
let l = finite(self.l).clamp(0.0, 100.0);
let c = finite(self.c).clamp(0.0, 230.0);
map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_lab)
}
}
impl Oklch {
pub fn from_color(c: Color) -> Self {
let (l, c, h) = polar(oklab_from_color(c), 0.000_2);
Self { l, c, h }
}
/// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show.
pub fn to_color(self) -> Color {
let l = finite(self.l).clamp(0.0, 1.0);
let c = finite(self.c).clamp(0.0, 0.5);
map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_oklab)
}
}
+105
View File
@@ -0,0 +1,105 @@
use super::*;
fn close(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
#[test]
fn lch_matches_css_reference_values() {
// CSS Color 4 values for sRGB primaries and white (D50 Lab).
let red = Lch::from_color(Color::from_hex("FF0000").unwrap());
assert!(close(red.l, 54.29, 0.05) && close(red.c, 106.84, 0.1) && close(red.h, 40.85, 0.1), "{red:?}");
let white = Lch::from_color(Color::WHITE);
assert!(close(white.l, 100.0, 0.01) && white.c < 0.05, "{white:?}");
assert_eq!(Lch::from_color(Color::BLACK).l.round(), 0.0);
}
#[test]
fn oklch_matches_reference_values() {
let red = Oklch::from_color(Color::from_hex("FF0000").unwrap());
assert!(close(red.l, 0.628, 0.001) && close(red.c, 0.2577, 0.001) && close(red.h, 29.23, 0.1), "{red:?}");
let white = Oklch::from_color(Color::WHITE);
assert!(close(white.l, 1.0, 0.001) && white.c < 0.001 && white.h == 0.0, "{white:?}");
}
#[test]
fn lch_and_oklch_round_trip_through_srgb() {
for hex in ["0D99FF", "FF0000", "00FF00", "0000FF", "FFFFFF", "000000", "808080", "663399", "F24E1E", "1ABC9C"] {
let c = Color::from_hex(hex).unwrap();
assert_eq!(Lch::from_color(c).to_color().to_hex(), hex, "{hex} via LCH");
assert_eq!(Oklch::from_color(c).to_color().to_hex(), hex, "{hex} via OKLCH");
}
}
#[test]
fn out_of_gamut_keeps_lightness_and_hue() {
// Far more chroma than sRGB holds: the chroma comes down, lightness and hue stay.
let asked = Oklch { l: 0.7, c: 0.45, h: 150.0 };
let got = Oklch::from_color(asked.to_color());
assert!(close(got.l, 0.7, 0.01) && close(got.h, 150.0, 1.5) && got.c < 0.45, "{got:?}");
let asked = Lch { l: 50.0, c: 200.0, h: 300.0 };
let got = Lch::from_color(asked.to_color());
assert!(close(got.l, 50.0, 1.0) && close(got.h, 300.0, 2.0) && got.c < 200.0, "{got:?}");
}
#[test]
fn junk_values_never_panic() {
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, -5.0, 0.0] {
let a = Lch { l: v, c: v, h: v }.to_color();
let b = Oklch { l: v, c: v, h: v }.to_color();
for x in [a.r, a.g, a.b, b.r, b.g, b.b] {
assert!(x.is_finite() && (0.0..=1.0).contains(&x));
}
}
let nan = Color { r: f32::NAN, g: 2.0, b: -1.0, a: 1.0 };
assert!(Lch::from_color(nan.sanitized()).l.is_finite());
assert!(Oklch::from_color(nan.sanitized()).l.is_finite());
}
#[test]
fn css_reads_lch_and_oklch() {
assert_eq!(Color::parse_css("oklch(62.8% 0.2577 29.23)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("oklch(0.628 0.2577 29.23deg / 50%)").unwrap().to_rgba8(), [255, 0, 0, 128]);
assert_eq!(Color::parse_css("lch(54.29 106.84 40.85)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("LCH(100% 0 0)").unwrap().to_hex(), "FFFFFF");
assert!(Color::parse_css("oklch(nan 0 0)").is_none());
assert!(Color::parse_css("lch(50)").is_none());
}
#[test]
fn css_hue_units_and_none() {
let blue = Color::parse_css("oklch(0.6 0.2 260)").unwrap();
for h in ["260deg", "0.7222222turn", "288.8888grad", "4.537856rad"] {
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
}
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
}
#[test]
fn css_lch_and_oklch_junk_is_refused_or_in_range() {
let long = "9".repeat(100_000);
let junk = [
"oklch()".to_string(),
"oklch(,,)".into(),
"lch(1 2 3 4 5)".into(),
"oklch(0.5 0.1 1e40)".into(),
"lch(inf 0 0)".into(),
"oklch(0.5 0.1 deg)".into(),
"lch(50 50 50 / / /)".into(),
"oklch(🟥 0 0)".into(),
"lch(\u{0} 0 0)".into(),
format!("oklch({long} {long} {long})"),
format!("lch(50 50 {long}turn)"),
];
for j in &junk {
if let Some(c) = Color::parse_css(j) {
for v in [c.r, c.g, c.b, c.a] {
assert!(v.is_finite() && (0.0..=1.0).contains(&v), "{j:.40}: {c:?}");
}
}
}
for j in ["oklch()", "lch(1 2 3 4 5)", "lch(inf 0 0)", "oklch(0.5 0.1 deg)", "oklch(🟥 0 0)", "oklch(0.5 0.1 1e40)"] {
assert!(Color::parse_css(j).is_none(), "{j}");
}
}
+63 -2
View File
@@ -11,10 +11,12 @@ use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod lch;
mod named;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
pub use lch::{Lch, Oklch};
pub use profile::{ColorProfile, ProfileConversion};
/// A non-premultiplied sRGB colour with alpha, components in `0..=1`. Deserializes from
@@ -147,7 +149,8 @@ impl Color {
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, `lch()`, `oklch()`, a CSS colour
/// name or `transparent`. LCH and OKLCH outside sRGB come back as the nearest sRGB colour.
pub fn parse_css(s: &str) -> Option<Self> {
let t = s.trim().to_ascii_lowercase();
if t.starts_with('#') {
@@ -186,6 +189,15 @@ impl Color {
}
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
// Percentages per CSS Color 4: OKLCH 100% is lightness 1, chroma 0.4; LCH 100% is 100 and 150.
if let Some(inner) = t.strip_prefix("oklch(").and_then(|r| r.strip_suffix(')')) {
let ([l, c, h], alpha) = css_polar(inner, 1.0, 0.4)?;
return Some(Oklch { l, c, h }.to_color().with_alpha(alpha));
}
if let Some(inner) = t.strip_prefix("lch(").and_then(|r| r.strip_suffix(')')) {
let ([l, c, h], alpha) = css_polar(inner, 100.0, 150.0)?;
return Some(Lch { l, c, h }.to_color().with_alpha(alpha));
}
if t == "transparent" {
return Some(Color::TRANSPARENT);
}
@@ -195,7 +207,14 @@ impl Color {
/// CSS `rgba(...)` / hex text for copy-as-CSS.
pub fn to_css(self) -> String {
let [r, g, b, _] = self.to_rgba8();
if self.a >= 0.999 { self.to_css_hex() } else { format!("rgba({r}, {g}, {b}, {:.2})", self.a) }
if self.a >= 0.999 {
return self.to_css_hex();
}
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
let short = format!("{:.2}", self.a);
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
format!("rgba({r}, {g}, {b}, {alpha})")
}
/// Linear interpolation in sRGB (as gradients render).
@@ -226,6 +245,46 @@ fn css_args(inner: &str) -> Vec<String> {
inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
}
/// The arguments of `lch()` / `oklch()`: lightness, chroma, hue and alpha. Percentages are of
/// `l_full` / `c_full`; `none` is 0.
fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
let parts = css_args(inner);
if !(3..=4).contains(&parts.len()) {
return None;
}
let l = css_number(parts.first()?, l_full)?;
let c = css_number(parts.get(1)?, c_full)?;
let h = css_hue(parts.get(2)?)?;
let alpha = match parts.get(3).map(String::as_str) {
None | Some("none") => 1.0,
Some(a) => parse_alpha(a)?,
};
Some(([l, c, h], alpha))
}
/// A CSS number, a percentage of `full` or `none` (0); finite only.
fn css_number(p: &str, full: f32) -> Option<f32> {
let v = match p.strip_suffix('%') {
_ if p == "none" => 0.0,
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
None => p.parse::<f32>().ok()?,
};
v.is_finite().then_some(v)
}
/// A CSS hue in degrees: a number, `deg`, `rad`, `grad`, `turn` or `none` (0); finite only.
fn css_hue(p: &str) -> Option<f32> {
if p == "none" {
return Some(0.0);
}
let (num, per_degree) = [("grad", 400.0 / 360.0), ("turn", 1.0 / 360.0), ("rad", std::f32::consts::PI / 180.0), ("deg", 1.0)]
.into_iter()
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
.unwrap_or((p, 1.0));
let v = num.parse::<f32>().ok()? / per_degree;
v.is_finite().then_some(v)
}
fn parse_alpha(a: &str) -> Option<f32> {
if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }
}
@@ -664,6 +723,8 @@ impl Paint {
#[cfg(test)]
mod gradient_tests;
#[cfg(test)]
mod lch_tests;
#[cfg(test)]
mod pattern_tests;
#[cfg(test)]
mod profile_tests;
+2 -2
View File
@@ -172,13 +172,13 @@ fn outside(l: f64) -> bool {
const ENCODE_STEPS: usize = 4096;
/// The sRGB transfer function, encoded value → linear light.
fn decode(v: f32) -> f64 {
pub(crate) fn decode(v: f32) -> f64 {
let v = f64::from(if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// The sRGB transfer function, linear light → encoded value (clipped to 0..=1).
fn encode(l: f64) -> f32 {
pub(crate) fn encode(l: f64) -> f32 {
let l = if l.is_finite() { l.clamp(0.0, 1.0) } else { 0.0 };
(if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }) as f32
}
+8
View File
@@ -133,3 +133,11 @@ fn malformed_alpha_or_extra_args_rejected() {
assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0);
assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5);
}
#[test]
fn to_css_round_trips_every_alpha() {
for a in 0..=255u8 {
let c = Color::from_rgba8(10, 20, 30, a);
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8(), c.to_rgba8(), "alpha {a}: {}", c.to_css());
}
}
+23 -4
View File
@@ -7,7 +7,7 @@
use figcraft_color::Color;
use figcraft_geom::kurbo::{Ellipse, RoundedRect};
use figcraft_geom::{BezPath, Point, Rect, Shape};
use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
use serde::{Deserialize, Serialize};
use crate::NodeId;
@@ -57,13 +57,16 @@ pub enum BoardItem {
},
/// A marker or highlighter stroke (what the eraser takes).
Ink,
/// A table: a frame of row frames holding cell frames with text (its column lines are thin
/// rectangles laid over it).
Table,
}
impl BoardItem {
/// Objects selected and moved as a whole: a click on their text or outline picks them, and
/// they never take other layers in.
pub fn is_atom(&self) -> bool {
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. })
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table)
}
/// Objects whose first text layer is their text (a double-click or Enter edits it).
@@ -109,6 +112,7 @@ impl BoardItem {
BoardItem::Label { .. } => "label",
BoardItem::Stamp { .. } => "stamp",
BoardItem::Ink => "ink",
BoardItem::Table => "table",
}
}
}
@@ -341,8 +345,11 @@ impl Stamp {
pub enum Routing {
#[default]
Straight,
/// Right angles only: out of each end along its side's normal, turning once or twice.
/// Right angles only: out of each end along its side's normal, turning once or twice, around
/// the objects it joins when the short way would cross them.
Elbow,
/// One smooth curve, leaving and arriving along the sides' normals.
Curved,
}
impl Routing {
@@ -350,10 +357,11 @@ impl Routing {
match self {
Routing::Straight => "straight",
Routing::Elbow => "elbow",
Routing::Curved => "curved",
}
}
pub fn from_id(s: &str) -> Option<Routing> {
[Routing::Straight, Routing::Elbow].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
[Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
}
}
@@ -397,6 +405,17 @@ impl Side {
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
/// The way out of the side (y down; none for [`Side::Auto`]).
pub fn normal(self) -> Vec2 {
match self {
Side::Top => Vec2::new(0.0, -1.0),
Side::Right => Vec2::new(1.0, 0.0),
Side::Bottom => Vec2::new(0.0, 1.0),
Side::Left => Vec2::new(-1.0, 0.0),
Side::Auto => Vec2::ZERO,
}
}
}
/// One end of a connector: attached to a layer, or a free point on the page.
+1
View File
@@ -28,6 +28,7 @@ fn colourful() -> (Document, NodeId) {
close_on_outside: false,
background: Some(RED),
offset: None,
anchor: Default::default(),
}],
transition: Default::default(),
duration_ms: 300,
+111
View File
@@ -0,0 +1,111 @@
//! A board's facilitation state, shared by everyone on it: the countdown timer and the vote.
//!
//! The timer keeps when it ends (or, paused, how long is left), never the ticks: each client works
//! out the time left from its own clock. A vote's ballots are kept per voter, so two people voting
//! at once change different entries.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{BoardItem, Document, NodeId};
/// The longest a timer runs (24 hours, in milliseconds).
pub const TIMER_MAX_MS: u64 = 24 * 60 * 60 * 1000;
/// The most votes one person gets in a vote.
pub const VOTES_MAX: u32 = 99;
/// A board's countdown.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum BoardTimer {
/// Counting down to `ends` (milliseconds since the Unix epoch).
Running { ends: u64 },
/// Stopped with `left` milliseconds to go.
Paused { left: u64 },
}
impl BoardTimer {
/// Milliseconds left at `now` (ms since the Unix epoch); 0 once it has run out.
pub fn left(&self, now: u64) -> u64 {
match *self {
BoardTimer::Running { ends } => ends.saturating_sub(now).min(TIMER_MAX_MS),
BoardTimer::Paused { left } => left.min(TIMER_MAX_MS),
}
}
}
/// What a vote is on.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum VoteScope {
/// Any object on the board.
#[default]
All,
/// Sticky notes only.
Stickies,
/// The objects that were selected when it started.
Only { ids: Vec<NodeId> },
}
impl VoteScope {
/// The object a vote at layer `leaf` goes to: the nearest layer from it up that the vote is
/// on (`All`: `whole`, the object a click there picks).
pub fn target(&self, doc: &Document, leaf: NodeId, whole: NodeId) -> Option<NodeId> {
if matches!(self, VoteScope::All) {
return self.takes(doc, whole).then_some(whole);
}
std::iter::once(leaf).chain(doc.ancestors(leaf)).find(|id| self.takes(doc, *id))
}
/// Whether object `id` takes votes.
pub fn takes(&self, doc: &Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
if doc.pages.contains(&id) || n.parent.is_none() {
return false;
}
match self {
VoteScope::All => true,
VoteScope::Stickies => matches!(n.board, Some(BoardItem::Sticky { .. })),
VoteScope::Only { ids } => ids.contains(&id),
}
}
}
/// A vote: how many votes each person has, what they go on, and whether it has ended (then
/// everyone's votes show). The ballots are [`Document::votes`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Vote {
pub per_person: u32,
#[serde(default)]
pub scope: VoteScope,
#[serde(default)]
pub ended: bool,
/// Who started it.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub by: String,
}
/// Every voter's ballot: the objects they put their votes on (once per vote).
pub type Ballots = BTreeMap<String, Vec<NodeId>>;
impl Vote {
/// The votes of `ballot` that count: on objects that are still there, up to the cap.
pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator<Item = NodeId> + 'a {
ballot.iter().copied().filter(|id| doc.node(*id).is_some()).take(self.per_person.min(VOTES_MAX) as usize)
}
/// The votes each object got, most first (ties in layer id order).
pub fn tally(&self, doc: &Document, ballots: &Ballots) -> Vec<(NodeId, u32)> {
let mut counts: BTreeMap<NodeId, u32> = BTreeMap::new();
for ballot in ballots.values() {
for id in self.counted(doc, ballot) {
*counts.entry(id).or_default() += 1;
}
}
let mut out: Vec<(NodeId, u32)> = counts.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
out
}
}
+29 -5
View File
@@ -11,6 +11,7 @@
pub mod board;
pub mod colors;
pub mod comments;
pub mod facilitation;
pub mod geometry;
pub mod handoff;
pub mod ids;
@@ -27,6 +28,7 @@ pub mod vector_regions;
pub use board::*;
pub use colors::*;
pub use comments::*;
pub use facilitation::*;
pub use figcraft_color as color;
pub use figcraft_geom as geom;
pub use handoff::*;
@@ -44,6 +46,10 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
/// Deepest descendant [`Document::world_render_bounds`] follows: as deep as the renderer draws
/// (`figcraft_render::MAX_EXPORT_DEPTH`), so a crafted chain can't overflow the stack (#1062).
const MAX_RENDER_DEPTH: usize = 256;
/// Stable node identifier, unique within a document.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
@@ -204,6 +210,15 @@ pub struct Document {
/// [`handoff`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dev_snapshots: BTreeMap<NodeId, Arc<DevSnapshot>>,
/// A board's countdown (see [`facilitation`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timer: Option<BoardTimer>,
/// A board's vote, running or ended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vote: Option<Vote>,
/// The vote's ballots, by voter.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub votes: Ballots,
}
impl Default for Document {
@@ -243,6 +258,9 @@ impl Document {
image_presets: Vec::new(),
annotation_categories: None,
dev_snapshots: BTreeMap::new(),
timer: None,
vote: None,
votes: BTreeMap::new(),
}
}
@@ -703,12 +721,16 @@ impl Document {
/// a clipping frame's own area, else that and what its visible children show (masked
/// children only within their mask). A group is what its children show, grown by its effects.
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
self.render_bounds_at(id, 0)
}
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
let n = self.node(id)?;
let own = n.own_paint_bounds(self.world_transform(id));
if n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children() {
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
return Some(own);
}
Some(match (self.children_render_bounds(n), &n.kind) {
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
(Some(kids), _) => own.union(kids),
(None, _) => own,
@@ -716,15 +738,15 @@ impl Document {
}
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
fn children_render_bounds(&self, n: &Node) -> Option<Rect> {
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
let mut out: Option<Rect> = None;
for run in self.paint_runs(n) {
let ids = n.children.get(run).unwrap_or(&[]);
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.world_render_bounds(*i)).reduce(|a, b| a.union(b));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
let r = match mask {
Some(m) => r.zip(self.world_render_bounds(m)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
None => r,
};
out = match (out, r) {
@@ -1248,6 +1270,8 @@ mod proto_expr_parse_tests;
#[cfg(test)]
mod proto_expr_tests;
#[cfg(test)]
mod proto_overlay_anchor_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod vector_regions_tests;
+51 -1
View File
@@ -1233,11 +1233,14 @@ pub enum ExportFormat {
Svg,
Pdf,
Webp,
/// Android Vector Drawable (an `.xml` drawable resource).
VectorDrawable,
}
impl ExportFormat {
/// Every format, in the order the export menus list them.
pub const ALL: [ExportFormat; 5] = [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp];
pub const ALL: [ExportFormat; 6] =
[ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp, ExportFormat::VectorDrawable];
/// Pixel formats (their size depends on the export scale).
pub fn is_raster(self) -> bool {
@@ -1252,9 +1255,15 @@ impl ExportFormat {
ExportFormat::Svg => "SVG",
ExportFormat::Pdf => "PDF",
ExportFormat::Webp => "WebP",
ExportFormat::VectorDrawable => "XML",
}
}
/// Formats whose output is vector (one file whatever the scale).
pub fn is_vector(self) -> bool {
matches!(self, ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable)
}
pub fn ext(self) -> &'static str {
match self {
ExportFormat::Png => "png",
@@ -1262,6 +1271,7 @@ impl ExportFormat {
ExportFormat::Svg => "svg",
ExportFormat::Pdf => "pdf",
ExportFormat::Webp => "webp",
ExportFormat::VectorDrawable => "xml",
}
}
pub fn from_ext(s: &str) -> Option<Self> {
@@ -1271,6 +1281,7 @@ impl ExportFormat {
"svg" => Some(ExportFormat::Svg),
"pdf" => Some(ExportFormat::Pdf),
"webp" => Some(ExportFormat::Webp),
"xml" | "vd" | "vectordrawable" => Some(ExportFormat::VectorDrawable),
_ => None,
}
}
@@ -1439,6 +1450,44 @@ pub struct ExportSetting {
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
pub vd_compat: VdCompat,
}
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VdCompat {
/// The closest drawing, with a warning for each approximation.
#[default]
BestEffort,
/// Never approximate: what can't be drawn exactly is left out, with a warning.
Exact,
/// Write what maps, leave out the rest, no warnings.
Ignore,
}
impl VdCompat {
pub const ALL: [VdCompat; 3] = [VdCompat::BestEffort, VdCompat::Exact, VdCompat::Ignore];
fn is_default(&self) -> bool {
*self == VdCompat::BestEffort
}
/// The name commands, files and the CLI use ("bestEffort").
pub fn key(self) -> &'static str {
match self {
VdCompat::BestEffort => "bestEffort",
VdCompat::Exact => "exact",
VdCompat::Ignore => "ignore",
}
}
pub fn from_key(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.key() == s)
}
}
impl Default for ExportSetting {
@@ -1453,6 +1502,7 @@ impl Default for ExportSetting {
svg_outline_text: true,
svg_id_attribute: false,
svg_simplify_stroke: true,
vd_compat: VdCompat::BestEffort,
}
}
}
+44
View File
@@ -507,6 +507,47 @@ impl OverlayPosition {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum OverlayAnchor {
#[default]
Screen,
TriggerInside,
TriggerOutside,
}
impl OverlayAnchor {
/// The default (the screen places the overlay), which files and JSON leave out.
pub fn is_screen(&self) -> bool {
*self == OverlayAnchor::Screen
}
/// Gives None for Screen and for Manual.
pub fn offset(self, position: OverlayPosition, trigger: [f64; 2], overlay: [f64; 2]) -> Option<[f64; 2]> {
use OverlayPosition as P;
let ([tw, th], [ow, oh]) = (trigger, overlay);
let outside = match self {
OverlayAnchor::Screen => return None,
OverlayAnchor::TriggerInside => false,
OverlayAnchor::TriggerOutside => true,
};
let x = match position {
P::TopLeft | P::BottomLeft => 0.0,
P::Center | P::TopCenter | P::BottomCenter => (tw - ow) / 2.0,
P::TopRight | P::BottomRight => tw - ow,
P::Manual => return None,
};
let y = match position {
P::TopLeft | P::TopCenter | P::TopRight if outside => -oh,
P::TopLeft | P::TopCenter | P::TopRight => 0.0,
P::BottomLeft | P::BottomCenter | P::BottomRight if outside => th,
P::BottomLeft | P::BottomCenter | P::BottomRight => th - oh,
P::Center | P::Manual => (th - oh) / 2.0,
};
Some([x, y])
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
@@ -538,6 +579,9 @@ pub enum Action {
/// opened it (x, y). None = right below that layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
offset: Option<[f64; 2]>,
/// The incumbent has no such option. The .fig export writes it as a manual offset.
#[serde(default, skip_serializing_if = "OverlayAnchor::is_screen")]
anchor: OverlayAnchor,
},
SwapOverlay {
dest: NodeId,
@@ -0,0 +1,61 @@
use serde_json::json;
use crate::NodeId;
use crate::proto::*;
const TRIGGER: [f64; 2] = [100.0, 40.0];
const OVERLAY: [f64; 2] = [60.0, 200.0];
fn at(anchor: OverlayAnchor, p: OverlayPosition) -> Option<[f64; 2]> {
anchor.offset(p, TRIGGER, OVERLAY)
}
#[test]
fn outside_sits_above_or_below_with_edges_lined_up() {
use OverlayPosition as P;
let out = OverlayAnchor::TriggerOutside;
assert_eq!(at(out, P::BottomLeft), Some([0.0, 40.0]));
assert_eq!(at(out, P::BottomCenter), Some([20.0, 40.0]));
assert_eq!(at(out, P::BottomRight), Some([40.0, 40.0]));
assert_eq!(at(out, P::TopLeft), Some([0.0, -200.0]));
assert_eq!(at(out, P::TopCenter), Some([20.0, -200.0]));
assert_eq!(at(out, P::TopRight), Some([40.0, -200.0]));
assert_eq!(at(out, P::Center), Some([20.0, -80.0]));
}
#[test]
fn inside_uses_the_presets_on_the_trigger() {
use OverlayPosition as P;
let ins = OverlayAnchor::TriggerInside;
assert_eq!(at(ins, P::TopLeft), Some([0.0, 0.0]));
assert_eq!(at(ins, P::TopRight), Some([40.0, 0.0]));
assert_eq!(at(ins, P::BottomLeft), Some([0.0, -160.0]));
assert_eq!(at(ins, P::BottomCenter), Some([20.0, -160.0]));
assert_eq!(at(ins, P::Center), Some([20.0, -80.0]));
}
#[test]
fn the_screen_and_manual_positions_have_no_preset_offset() {
assert_eq!(at(OverlayAnchor::Screen, OverlayPosition::BottomLeft), None);
assert_eq!(at(OverlayAnchor::TriggerOutside, OverlayPosition::Manual), None);
assert_eq!(at(OverlayAnchor::TriggerInside, OverlayPosition::Manual), None);
}
#[test]
fn json_keeps_the_anchor_and_old_actions_stay_on_the_screen() {
let a = Action::OpenOverlay {
dest: NodeId(7),
position: OverlayPosition::BottomLeft,
close_on_outside: true,
background: None,
offset: None,
anchor: OverlayAnchor::TriggerOutside,
};
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v["anchor"], "triggerOutside");
assert_eq!(serde_json::from_value::<Action>(v).unwrap(), a);
let old: Action = serde_json::from_value(json!({"type": "openOverlay", "dest": 7, "position": "bottomLeft"})).unwrap();
assert!(matches!(old, Action::OpenOverlay { anchor: OverlayAnchor::Screen, .. }));
assert!(serde_json::to_value(&old).unwrap().get("anchor").is_none());
}
+18
View File
@@ -560,6 +560,24 @@ fn is_ancestor_holds_for_very_deep_chains() {
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
}
#[test]
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
// it's within the renderer's depth and is left out past it, as the renderer leaves it out.
let chain = |depth: usize| {
let mut d = Document::new("x");
let mut parent = page(&d);
for _ in 0..depth {
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
}
d.create(parent, NodeKind::Rectangle, "R", 100.0, 100.0, 10.0, 10.0).unwrap();
let top = d.node(page(&d)).unwrap().children[0];
d.world_render_bounds(top).unwrap()
};
assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0);
assert_eq!(chain(20_000).x1, 10.0);
}
#[test]
fn text_insert_at_run_boundary_keeps_runs_disjoint() {
let mut t = TextData::new("0123456789");
+85 -5
View File
@@ -120,23 +120,40 @@ impl TextCase {
}
/// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises
/// the first letter of each word even when a word spans several styled runs).
/// the first letter of each word even when a word spans several styled runs). An apostrophe
/// as `prev` is taken to continue a word.
pub fn apply_after(self, s: &str, prev: Option<char>) -> String {
self.apply_impl(s, prev, prev.is_some_and(is_word_char))
}
/// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe
/// only continues a word when a letter or digit precedes it.
pub fn apply_between(self, s: &str, before: Option<char>, prev: Option<char>) -> String {
let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric)));
self.apply_impl(s, prev, continues)
}
/// `in_word` says whether `prev` continues a word (rather than a new word starting here).
fn apply_impl(self, s: &str, prev: Option<char>, in_word: bool) -> String {
match self {
TextCase::Original | TextCase::SmallCaps => s.to_string(),
TextCase::Upper => s.to_uppercase(),
TextCase::Lower => s.to_lowercase(),
TextCase::Title => {
let mut out = String::with_capacity(s.len());
let mut start = prev.is_none_or(|c| !is_word_char(c));
let mut start = !in_word;
let mut last = prev;
for ch in s.chars() {
if start && ch.is_alphanumeric() {
out.extend(ch.to_uppercase());
start = false;
} else {
out.push(ch);
start = !is_word_char(ch);
// An apostrophe only continues a word right after a letter or digit.
let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric);
start = !is_word_char(ch) || opening_apostrophe;
}
last = Some(ch);
}
out
}
@@ -253,10 +270,17 @@ pub struct TextStyle {
pub style_id: Option<String>,
}
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
Some(family) if !family.is_empty() => family,
_ => "Inter",
};
impl Default for TextStyle {
fn default() -> Self {
Self {
font_family: "Inter".into(),
font_family: DEFAULT_FONT.into(),
font_style: "Regular".into(),
font_size: 12.0,
line_height: LineHeight::Auto,
@@ -410,7 +434,8 @@ pub struct TextData {
/// line's baseline).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub leading_trim: LeadingTrim,
/// Max lines when truncating (0 = as many as fit).
/// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many
/// as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false).
@@ -503,6 +528,28 @@ impl TextData {
self.characters.chars().count()
}
/// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height
/// with a line limit (the box hugs up to `max_lines` lines).
pub fn truncates(&self) -> bool {
match self.auto_resize {
TextAutoResize::Truncate => true,
TextAutoResize::Height => self.max_lines > 0,
TextAutoResize::WidthAndHeight | TextAutoResize::None => false,
}
}
/// The resize mode once the box is given an explicit `width` and/or `height`: an explicit
/// height fixes the box, an explicit width turns auto width into auto height, and truncating
/// text keeps truncating.
pub fn fix_size(&mut self, width: bool, height: bool) {
self.auto_resize = match self.auto_resize {
_ if height && self.truncates() => TextAutoResize::Truncate,
_ if height => TextAutoResize::None,
TextAutoResize::WidthAndHeight if width => TextAutoResize::Height,
mode => mode,
};
}
/// The style at char index `i`.
pub fn style_at(&self, i: usize) -> &TextStyle {
self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style)
@@ -790,6 +837,18 @@ mod case_tests {
assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}"));
}
#[test]
fn new_text_starts_in_the_builds_default_font() {
// Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter).
let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty());
assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter"));
let st = TextStyle::default();
assert_eq!(st.font_family, DEFAULT_FONT);
// The family is always written, so a saved file keeps it in a build with another default.
let saved = serde_json::to_value(&st).unwrap();
assert_eq!(saved["font_family"], DEFAULT_FONT);
}
#[test]
fn variations_are_backward_compatible_and_imply_weight() {
// Files written before variable fonts load with no custom axes, and stay byte-identical.
@@ -838,3 +897,24 @@ mod case_tests {
assert_eq!(t.para_list(0), ParaList::default());
}
}
#[cfg(test)]
mod title_case_tests {
use super::TextCase;
#[test]
fn title_case_after_opening_apostrophe() {
assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'");
assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'");
assert_eq!(TextCase::Title.apply("don't"), "Don't");
assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello");
}
#[test]
fn title_case_between_runs_sees_char_before_apostrophe() {
assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello");
assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T");
}
}
+4
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
figcraft-vectordrawable = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
@@ -28,8 +29,11 @@ thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
deunicode = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
[dev-dependencies]
zip = { workspace = true }
wat = { workspace = true }
image = { workspace = true }
+7
View File
@@ -0,0 +1,7 @@
pub mod components;
pub mod library;
pub mod library_server;
pub mod library_swap;
pub mod playground;
pub mod style_vars;
pub mod token_roles;
+301
View File
@@ -0,0 +1,301 @@
//! Components and instances.
//!
//! An instance's layers are real nodes copied from its main component; each copy records the
//! main-component node it mirrors (`source`) and which property groups were changed locally
//! (`overrides`). When a main component changes, every instance is rebuilt from it, keeping layer
//! ids and re-applying overrides (matched by source, then by layer name path, so switching
//! variants keeps compatible overrides). Variants are components inside a component set, named
//! `Property=Value, …`; component properties (boolean, text, instance swap) are applied through
//! `prop_refs` on the layers they control.
//!
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
//! inside an instance, `modes` (the variable modes a layer inside an instance chooses),
//! `component` (a swapped nested instance), `props` (property values of a nested instance) and
//! `slot` (a slot holding the instance's own content). An instance's own export settings,
//! interactions, resizing and variable modes are its own, like its place. `position`
//! overrides of layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
//!
//! Switching variants keeps an override where the layer's name path matches and the two variants
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
//! only text layers' overrides ([`keep_text_overrides`]).
//!
//! Instances never gain or lose layers of their own: commands that would add, remove or reorder
//! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A
//! slot is a frame of a main component bound to a slot property (`prop_refs["slot"]`). An
//! instance's copy of it shows the main's default content until its layers are edited; from then
//! on (override group `slot`) its children are the instance's own content: free to edit, kept
//! across syncs and variant switches, and outside the instance's structure (instances placed in it
//! stand alone).
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_geom::Affine;
use serde_json::Value;
mod diff;
mod guard;
mod instances;
mod patch;
mod rebase;
mod sync;
mod variants;
pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups};
pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers};
pub(crate) use instances::copy_group;
pub use instances::{PROP_BINDING, copy_in_file, create_instance, create_instance_at, sync_instance};
pub use patch::sync_after_edit;
#[cfg(test)]
pub(crate) use patch::{FULL_REBUILD, PATCHED};
#[cfg(test)]
use rebase::RebaseSources;
pub use rebase::rebase_instance;
pub use sync::keep_text_overrides;
pub use variants::{default_variant, exposed_copies, find_variant, mirror_issues, variant_conflicts, variant_name, variant_options, variant_props};
/// Nesting limit for instances inside instances.
const MAX_DEPTH: usize = 16;
/// The override group (and `prop_refs` field) of slots.
pub const SLOT: &str = "slot";
/// Every override group a layer can carry.
pub const OVERRIDE_GROUPS: &[&str] = &[
"fills",
"strokes",
"effects",
"opacity",
"visible",
"name",
"corners",
"characters",
"textStyle",
"padding",
"gap",
"alignment",
"grids",
"exports",
"interactions",
"size",
"resizing",
"position",
"modes",
"component",
"props",
SLOT,
];
fn is_instance(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))
}
/// A slot (inside an instance) whose children are the instance's own content.
fn holds_content(n: &Node) -> bool {
n.overrides.contains(SLOT)
}
/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content:
/// content belongs to no instance (it edits freely, and instances placed in it stand alone).
fn scope(doc: &Document, id: NodeId) -> Vec<NodeId> {
let mut out = vec![id];
for a in doc.ancestors(id) {
if doc.node(a).is_some_and(holds_content) {
break;
}
out.push(a);
}
out
}
/// The component a node belongs to (itself, or the nearest component ancestor).
pub fn owning_component(doc: &Document, id: NodeId) -> Option<NodeId> {
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })))
}
/// The innermost instance containing `id` (`id` itself when it is an instance).
pub fn owning_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
scope(doc, id).into_iter().find(|c| is_instance(doc, *c))
}
/// The outermost instance containing `id` (`id` itself when it is a top-level instance).
pub fn root_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c))
}
/// The innermost instance `id` is a layer of (never `id` itself).
fn enclosing_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c))
}
/// Whether `id` is a layer inside an instance (not the outermost instance itself).
pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
enclosing_instance(doc, id).is_some()
}
/// Mark the `modes` override on layers inside instances whose variable modes differ from their
/// main layer's, for files from before `modes` was an override group. Copies follow their main
/// layer's modes, so a layer on other modes chose them itself.
pub fn mark_layer_modes(doc: &mut Document) {
let chose: Vec<NodeId> = doc
.nodes()
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
.map(|n| n.id)
.filter(|id| inside_instance(doc, *id))
.collect();
for id in chose {
if let Some(n) = doc.node_mut(id) {
n.overrides.insert("modes".into());
}
}
}
/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
/// a slot property of its component, or an instance's copy of one (followed through its sources).
pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
let mut cur = id;
for _ in 0..=MAX_DEPTH {
let n = doc.node(cur)?;
if !matches!(n.kind, NodeKind::Frame) {
return None;
}
if let Some(name) = n.prop_refs.get(SLOT) {
let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None };
return match props.get(name) {
Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())),
_ => None,
};
}
cur = n.source?;
}
None
}
/// The name of the slot property frame `id` stands for ([`slot_def`]).
pub fn slot_name(doc: &Document, id: NodeId) -> Option<String> {
slot_def(doc, id).map(|(name, _)| name.to_string())
}
/// Whether `id` is a slot (in a main component, or an instance's copy of one).
pub fn is_slot(doc: &Document, id: NodeId) -> bool {
slot_def(doc, id).is_some()
}
/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's
/// default content.
pub fn slot_edited(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(holds_content)
}
/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and
/// reordered freely.
pub fn slot_scope(doc: &Document, id: NodeId) -> Option<NodeId> {
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
for c in chain {
if is_instance(doc, c) {
return None;
}
if is_slot(doc, c) {
return Some(c);
}
}
None
}
/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an
/// instance, outside its slots.
pub fn structure_locked(doc: &Document, id: NodeId) -> bool {
owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none()
}
/// For tree walks that look for where layers can go: whether the children of `id` are locked (in
/// an instance, outside its slots), given whether `id` itself is (`locked`).
pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool {
is_instance(doc, id) || (locked && !is_slot(doc, id))
}
/// Whether layer `id`'s place (parent and position among its siblings) changed between two
/// versions only where instance structure allows it.
pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool {
let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id)));
let (was, is) = (place(before), place(after));
was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p)))
}
/// The slots of a component or instance (its own: not those of instances nested in it, nor of the
/// content put into its slots), with their property names, top-most first.
pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
let mut seen = 0usize;
while let Some(c) = stack.pop() {
seen += 1;
if seen > 1_000_000 {
break;
}
let Some(n) = doc.node(c) else { continue };
if let Some(name) = slot_name(doc, c) {
out.push((c, name));
}
if !is_instance(doc, c) && !holds_content(n) {
stack.extend(n.children.iter().rev().copied());
}
}
out
}
/// All instances of `component` in the document.
pub fn instances_of(doc: &Document, component: NodeId) -> Vec<NodeId> {
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect()
}
/// The component set a variant belongs to.
pub fn variant_set(doc: &Document, component: NodeId) -> Option<NodeId> {
doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })))
}
/// The layer name a fresh instance of `component` gets: the set's name for variants.
pub fn default_instance_name(doc: &Document, component: NodeId) -> String {
let set = variant_set(doc, component).and_then(|s| doc.node(s));
set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into())
}
/// Default property values of a component (slots hold content, not values: they have none).
pub fn default_props(comp: &Node) -> BTreeMap<String, Value> {
let mut out = BTreeMap::new();
if let NodeKind::Component { props, .. } = &comp.kind {
for (k, d) in props {
if takes_value(d) {
out.insert(k.clone(), prop_default_value(d));
}
}
}
out
}
/// Whether instances store a value for the property (every kind but slots).
pub fn takes_value(d: &PropDef) -> bool {
!matches!(d, PropDef::Slot { .. })
}
/// A property definition's default as a JSON value.
pub fn prop_default_value(d: &PropDef) -> Value {
match d {
PropDef::Boolean { default } => Value::Bool(*default),
PropDef::Text { default } => Value::String(default.clone()),
PropDef::InstanceSwap { default, .. } => Value::from(default.0),
PropDef::Variant { default, .. } => Value::String(default.clone()),
PropDef::Slot { .. } => Value::Null,
}
}
#[cfg(test)]
mod rebase_source_tests;
#[cfg(test)]
pub(crate) mod tests;
+218
View File
@@ -0,0 +1,218 @@
use super::instances::PROP_BINDING;
use super::*;
/// The property groups whose value layer `n` takes from the same variables as its main layer
/// `src` (they differ only where the two resolve in different variable modes).
pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> {
let mut out = Vec::new();
for (k, v) in &n.bound {
let Some(g) = binding_group(k) else { continue };
if src.bound.get(k) == Some(v) && !out.contains(&g) {
out.push(g);
}
}
out
}
/// The override group a variable binding key feeds (`None` for component property and layout
/// grid bindings, which follow other rules).
pub(super) fn binding_group(key: &str) -> Option<&'static str> {
use crate::cmd::lists::{BoundList, list_binding};
if key.starts_with(PROP_BINDING) {
return None;
}
match list_binding(key).map(|(l, _, _)| l) {
Some(BoundList::Fills) => Some("fills"),
Some(BoundList::Strokes) => Some("strokes"),
Some(BoundList::Effects) => Some("effects"),
Some(BoundList::Grids) => None,
None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"),
}
}
/// The variable bindings of `n` that feed group `g`.
pub(super) fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator<Item = (&'a String, &'a String)> + 'a {
n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g))
}
/// Property groups that differ between two versions of a node.
pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
let mut out = Vec::new();
if a.fills != b.fills {
out.push("fills");
}
if a.strokes != b.strokes || a.stroke != b.stroke {
out.push("strokes");
}
if a.effects != b.effects {
out.push("effects");
}
if a.opacity != b.opacity || a.blend != b.blend {
out.push("opacity");
}
if a.visible != b.visible {
out.push("visible");
}
if a.name != b.name {
out.push("name");
}
if a.corners != b.corners {
out.push("corners");
}
if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
out.push("size");
}
if resizing(a) != resizing(b) {
out.push("resizing");
}
if a.transform != b.transform {
out.push("position");
}
if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) {
if x.characters != y.characters {
out.push("characters");
}
if text_style_differs(x, y) {
out.push("textStyle");
}
}
if let (Some(x), Some(y)) = (&a.layout, &b.layout) {
if x.padding != y.padding {
out.push("padding");
}
if x.gap != y.gap || x.cross_gap != y.cross_gap {
out.push("gap");
}
if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align {
out.push("alignment");
}
}
if a.grids != b.grids || a.styles.grid != b.styles.grid {
out.push("grids");
}
if a.exports != b.exports {
out.push("exports");
}
if a.interactions != b.interactions {
out.push("interactions");
}
if a.var_modes != b.var_modes {
out.push("modes");
}
if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
if x != y {
out.push("component");
}
if px != py {
out.push("props");
}
}
out
}
/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
fn resizing(n: &Node) -> (Sizing, Sizing, [Option<f64>; 4]) {
let c = &n.child_layout;
(c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
}
/// Whether two texts differ in anything but their characters (the `textStyle` group).
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
x.style != y.style
|| x.runs != y.runs
|| x.auto_resize != y.auto_resize
|| x.align_h != y.align_h
|| x.align_v != y.align_v
|| x.paragraph_spacing != y.paragraph_spacing
|| x.paragraph_indent != y.paragraph_indent
|| x.list != y.list
|| x.paragraphs != y.paragraphs
|| x.list_spacing != y.list_spacing
|| x.hanging_punctuation != y.hanging_punctuation
|| x.hanging_list != y.hanging_list
|| x.max_lines != y.max_lines
|| x.features != y.features
}
/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text.
pub(super) fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool {
for a in doc.ancestors(id) {
let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue };
if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 {
return true;
}
}
let Some(n) = doc.node(id) else { return true };
if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute {
return true;
}
if group == "size"
&& let NodeKind::Text(t) = &n.kind
{
let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6);
return match t.auto_resize {
TextAutoResize::WidthAndHeight => true,
TextAutoResize::Height => !wchanged,
_ => false,
};
}
false
}
/// The components sharing the properties of component `c`: the variants of its set, or `c` alone.
pub fn property_owners(doc: &Document, c: NodeId) -> Vec<NodeId> {
match variant_set(doc, c).and_then(|s| doc.node(s)) {
Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(),
None => vec![c],
}
}
/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding
/// their layers bound to it: on a main component, a boolean property's value is the visibility of
/// its layers. Returns the components and layers it changed.
pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec<NodeId> {
let mut out = Vec::new();
for &c in comps {
let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue };
let Some(PropDef::Boolean { default }) = props.get(name) else { continue };
if *default != value
&& let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind)
{
props.insert(name.to_string(), PropDef::Boolean { default: value });
out.push(c);
}
// The main's own layers (not those of instances nested in it, which follow their mains).
let layers: Vec<NodeId> = doc
.subtree(c)
.into_iter()
.filter(|d| *d != c && !inside_instance(doc, *d))
.filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name)))
.collect();
for d in layers {
if let Some(n) = doc.node_mut(d) {
n.visible = value;
out.push(d);
}
}
}
out
}
/// A main component's layer bound to a boolean property was shown or hidden: that is the
/// property's new default, which its other bound layers (in every variant) take too. Returns the
/// components and layers that changed as a result.
pub(super) fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec<NodeId> {
let mut out = Vec::new();
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
if n.visible == b.visible || inside_instance(doc, id) {
continue;
}
let Some(name) = n.prop_refs.get("visible").cloned() else { continue };
let visible = n.visible;
let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue };
out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible));
}
out
}
@@ -0,0 +1,135 @@
use super::diff::geometry_derived;
use super::rebase::rebase_instance;
use super::variants::instance_issues;
use super::*;
/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the
/// main's default content until now) stop mirroring the main, and instances among them stand
/// alone, linked to their own mains.
pub(super) fn own_slot_content(doc: &mut Document, slot: NodeId) {
if let Some(n) = doc.node_mut(slot) {
n.overrides.insert(SLOT.into());
}
for nested in unlink_layers(doc, slot, None) {
rebase_instance(doc, nested);
}
}
/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the
/// content of a slot): plain layers forget the main layer they mirrored and their property
/// bindings. The instances among them are returned, to re-link to their own mains (with
/// [`rebase_instance`]) once the caller is done.
pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option<NodeId>) -> Vec<NodeId> {
let picked: Vec<(NodeId, bool)> =
doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect();
let mut nested = Vec::new();
for (d, instance) in picked {
let Some(n) = doc.node_mut(d) else { continue };
n.prop_refs.clear();
if instance {
nested.push(d);
} else {
n.source = None;
n.overrides.clear();
}
}
nested
}
/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it
/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept).
pub(super) fn relink_copy(doc: &mut Document, id: NodeId) {
let Some(n) = doc.node(id) else { return };
match &n.kind {
NodeKind::Instance { component, .. } => {
let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }));
if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) {
rebase_instance(doc, id);
}
}
_ => {
if (n.source.is_some() || !n.overrides.is_empty())
&& owning_instance(doc, id).is_none()
&& let Some(m) = doc.node_mut(id)
{
m.source = None;
m.overrides.clear();
}
}
}
}
/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside
/// its slots.
pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> {
for &id in after.dirty() {
let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue };
// Library copies change only through library updates (and are read-only otherwise).
if n.children == b.children || after.is_remote(id) {
continue;
}
if structure_locked(before, id) && structure_locked(after, id) {
let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default();
return Err(format!(
"layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first"
));
}
}
Ok(())
}
/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled
/// back.
pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> {
match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) {
Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")),
None => Ok(()),
}
}
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer
/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also
/// when the main changed the same way in this edit (a library update).
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
let (n, b) = (after.node(id)?, before.node(id)?);
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
return None;
}
let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?);
let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode);
// Auto layout settings other than the overridable ones.
let settings = |x: &Node| {
x.layout.clone().map(|mut l| {
(l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min);
l.content_align = ContentAlign::Auto;
l
})
};
// Child layout settings other than the resizing ones.
let placing = |x: &Node| {
let mut c = x.child_layout.clone();
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
c
};
if mode(n) != mode(b) && mode(ma) == mode(mb) {
return Some("The auto layout direction");
}
if settings(n) != settings(b) && settings(ma) == settings(mb) {
return Some("This auto layout setting");
}
if !inside_instance(after, id) || !inside_instance(before, id) {
return None;
}
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
return Some("A layer's position");
}
if n.constraints != b.constraints && ma.constraints == mb.constraints {
return Some("A layer's constraints");
}
if placing(n) != placing(b) && placing(ma) == placing(mb) {
return Some("A layer's place in auto layout");
}
None
}
@@ -0,0 +1,342 @@
use super::diff::{binding_group, bindings_of};
use super::sync::{insert_linked, sync_instance_depth};
use super::*;
/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result<NodeId, DocError> {
create_instance_at(doc, component, parent, x, y, None)
}
/// [`create_instance`] at child `index` of `parent` (None: on top).
pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option<usize>) -> Result<NodeId, DocError> {
let comp = doc.get(component)?.clone();
if !matches!(comp.kind, NodeKind::Component { .. }) {
return Err(DocError::Invalid(format!("{component} is not a component")));
}
if doc.is_ancestor(component, parent) {
return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
}
let id = doc.alloc_id();
let mut root = comp.clone();
root.id = id;
root.name = default_instance_name(doc, component);
root.kind = NodeKind::Instance { component, props: default_props(&comp) };
root.children.clear();
root.parent = None;
root.source = Some(component);
root.overrides.clear();
root.transform = Affine::translate((x, y));
root.prop_refs.clear();
root.annotations.clear();
root.code_links.clear();
// The main's dev resources show on its instances without being copied.
root.dev_resources.clear();
// Measurements belong to the layer they were placed on.
root.measurements.clear();
// The main's interactions play on its instances in prototypes; the instance's own start empty.
root.interactions.clear();
// So do the variable modes the main sets (see `Document::inherited_modes`).
root.var_modes.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
}
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
/// has one main per component, so a main component's copy is an instance of it; a variant copied
/// into its set stays a new variant, and every other layer is deep-copied.
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
let n = doc.get(id)?;
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
return doc.duplicate_into(id, parent, index);
}
let transform = n.transform;
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
doc.get_mut(instance)?.transform = transform;
Ok(instance)
}
/// Which part of the carried overrides to apply.
#[derive(Clone, Copy, PartialEq)]
pub(super) enum Carry {
/// Paint, text, visibility, … (everything but geometry).
Values,
/// Values plus size (instance roots: their size decides how children follow constraints).
Root,
/// Size and position (children, after constraints ran).
Geometry,
}
/// Copy the overridden property groups of `from` onto `to`.
pub(super) fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
for g in &from.overrides {
let geo = matches!(g.as_str(), "size" | "position");
match what {
Carry::Values if geo => continue,
Carry::Root if g == "position" => continue,
Carry::Geometry if !geo => continue,
_ => {}
}
copy_group(from, to, g);
}
if what != Carry::Geometry {
to.overrides = from.overrides.clone();
}
}
/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis)
/// that is still `was`'s follows `new`; what the instance set itself stays in `own`.
pub(super) fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) {
if own.sizing_h == was.sizing_h {
own.sizing_h = new.sizing_h;
}
if own.sizing_v == was.sizing_v {
own.sizing_v = new.sizing_v;
}
for (o, w, n) in [
(&mut own.min_w, was.min_w, new.min_w),
(&mut own.max_w, was.max_w, new.max_w),
(&mut own.min_h, was.min_h, new.min_h),
(&mut own.max_h, was.max_h, new.max_h),
] {
if *o == w {
*o = n;
}
}
}
/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them.
pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) {
to.bound.retain(|k, _| binding_group(k) != Some(g));
to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone())));
}
match g {
"fills" => {
to.fills = from.fills.clone();
to.styles.fill = from.styles.fill.clone();
}
"strokes" => {
to.strokes = from.strokes.clone();
to.stroke = from.stroke.clone();
to.styles.stroke = from.styles.stroke.clone();
}
"effects" => {
to.effects = from.effects.clone();
to.styles.effect = from.styles.effect.clone();
}
"opacity" => {
to.opacity = from.opacity;
to.blend = from.blend;
}
"visible" => to.visible = from.visible,
"name" => to.name = from.name.clone(),
"corners" => to.corners = from.corners,
"characters" => {
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.characters = a.characters.clone();
b.runs = a.runs.clone();
b.paragraphs = a.paragraphs.clone();
}
}
"textStyle" => {
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.style = a.style.clone();
b.runs = a.runs.clone();
b.auto_resize = a.auto_resize;
b.align_h = a.align_h;
b.align_v = a.align_v;
b.paragraph_spacing = a.paragraph_spacing;
b.paragraph_indent = a.paragraph_indent;
b.list = a.list;
b.paragraphs = a.paragraphs.clone();
b.list_spacing = a.list_spacing;
b.hanging_punctuation = a.hanging_punctuation;
b.hanging_list = a.hanging_list;
b.max_lines = a.max_lines;
b.features = a.features.clone();
}
to.styles.text = from.styles.text.clone();
}
"padding" | "gap" | "alignment" => {
if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) {
match g {
"padding" => b.padding = a.padding,
"gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap),
_ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align),
}
}
}
"grids" => {
to.grids = from.grids.clone();
to.styles.grid = from.styles.grid.clone();
}
"exports" => to.exports = from.exports.clone(),
"interactions" => to.interactions = from.interactions.clone(),
"size" => {
to.width = from.width;
to.height = from.height;
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.auto_resize = a.auto_resize;
}
}
"resizing" => {
let (a, b) = (&from.child_layout, &mut to.child_layout);
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
}
"position" => to.transform = from.transform,
"modes" => to.var_modes = from.var_modes.clone(),
"component" => {
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
*b = *a;
}
}
"props" => {
if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) {
*b = a.clone();
}
carry_prop_bindings(from, to);
}
_ => {}
}
}
/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With
/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames
/// don't break matching), and the content of slots holding the instance's own is left out.
pub(super) fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap<NodeId, String> {
let mut out = BTreeMap::new();
let mut stack = vec![(root, String::new())];
while let Some((id, prefix)) = stack.pop() {
let Some(n) = doc.node(id) else { continue };
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
for c in &n.children {
let Some(cn) = doc.node(*c) else { continue };
// A nested instance linked only to its own component so far (an imported one) mirrors no
// layer yet: it goes by its own name, like the main's layer it will be matched with.
let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component));
let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s));
let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str());
let k = seen.entry(name).or_insert(0);
let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") };
*k += 1;
let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") };
out.insert(*c, path.clone());
if out.len() > 1_000_000 {
return out;
}
if !(src_names && holds_content(cn)) {
stack.push((*c, path));
}
}
}
out
}
/// The binding key prefix of an instance's component property value
/// (`componentProperties/<name>`): a boolean property takes a boolean variable, a text property a
/// string variable.
pub const PROP_BINDING: &str = "componentProperties/";
/// Give `to` the component property bindings of instance `from` (they go with its property
/// values, which an instance keeps across rebuilds).
pub(super) fn carry_prop_bindings(from: &Node, to: &mut Node) {
to.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone())));
}
/// The override group a property-bound field belongs to.
pub(super) fn field_group(field: &str) -> &'static str {
match field {
"visible" => "visible",
"characters" => "characters",
"mainComponent" => "component",
_ => "",
}
}
/// Apply one component-property value to a layer.
pub(super) fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
match field {
"visible" => {
if let Some(b) = val.as_bool() {
n.visible = b;
}
}
"characters" => {
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
&& t.characters != sv
{
let len = t.char_count();
t.replace_range(0, len, &sv);
}
}
"mainComponent" => {
if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val))
&& doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }))
{
*c = v;
}
}
_ => {}
}
}
/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its
/// slots. An instance whose main component was deleted keeps its layers as they are.
pub fn sync_instance(doc: &mut Document, inst: NodeId) {
// Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits).
let old: Vec<(std::sync::Arc<Node>, bool)> =
doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect();
let kept = slot_content(doc, inst);
sync_instance_depth(doc, inst, 0, BTreeMap::new());
restore_slot_content(doc, kept);
for (a, was_dirty) in old {
doc.keep_if_unchanged(a, was_dirty);
}
}
/// The slots in instance `inst` (nested instances included) holding the instance's own content:
/// each with its content (the subtrees of its children, pre-order).
fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec<Node>)> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default();
while let Some(c) = stack.pop() {
let Some(n) = doc.node(c) else { continue };
if holds_content(n) {
let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect();
out.push((c, nodes));
} else {
stack.extend(n.children.iter().copied());
}
}
out
}
/// Put slot content back after a rebuild, into the slots that still hold the instance's own
/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints
/// and geometry overrides ran before, so the content keeps its place.
fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec<Node>)>) {
for (slot, nodes) in kept {
let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id));
if !keep {
continue;
}
let ids: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
for n in nodes {
// Pre-order: every parent is back before its children.
let _ = insert_linked(doc, n);
}
// Never leave links to a layer that couldn't come back.
for id in ids.into_iter().chain([slot]) {
let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c)));
if dangling {
let live: Vec<NodeId> = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default();
if let Some(n) = doc.node_mut(id) {
n.children = live;
}
}
}
}
}
@@ -0,0 +1,528 @@
use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaults, geometry_derived, variable_groups};
use super::guard::{own_slot_content, relink_copy};
use super::instances::{copy_group, field_group, sync_instance};
use super::sync::insert_linked;
use super::*;
/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
/// whose properties or main were switched, or whose overrides were reset.
pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet<NodeId>) {
let mut changed = changed.to_vec();
for id in follow_boolean_defaults(doc, before, &changed) {
if !changed.contains(&id) {
changed.push(id);
}
}
let changed = changed.as_slice();
// 0. A slot whose layers were added, removed or reordered holds the instance's own content
// from now on; copies of instance layers placed elsewhere stop mirroring their main.
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
if n.children == b.children || owning_instance(doc, id).is_none() {
continue;
}
if let Some(slot) = slot_scope(doc, id)
&& !slot_edited(doc, slot)
{
own_slot_content(doc, slot);
}
}
for &id in changed {
if !before.contains(id) {
relink_copy(doc, id);
}
}
let mut resync: BTreeSet<NodeId> = BTreeSet::new();
// 1. Overrides for layers edited inside instances.
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
let Some(inst) = owning_instance(doc, id) else { continue };
let top_root = inst == id && !inside_instance(doc, id);
if top_root
&& let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
&& (c1 != c2 || p1 != p2)
{
// Switching the main or a property value of an instance isn't an override.
resync.insert(id);
}
let raw = diff_groups(b, n);
let mut groups = raw.clone();
// An instance's own place, resizing, export settings, interactions and variable modes are
// its own, not overrides.
if top_root {
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
}
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
}
// A value equal to the main's isn't an override (setting it back clears the override).
let mut cleared = Vec::new();
if let Some(src) = n.source.and_then(|s| doc.node(s)) {
let mut differs = diff_groups(src, n);
if inst == id {
differs.retain(|g| *g != "name");
if n.name != default_instance_name(doc, src.id) {
differs.push("name");
}
}
// A value from the same variable as the main's (resolved in this layer's modes) isn't an
// override either.
let from_variables = variable_groups(n, src);
groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
// Binding another variable (or none) where the main binds one is an override.
for (k, _) in b.bound.iter().chain(n.bound.iter()) {
let Some(g) = binding_group(k) else { continue };
let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
if rebound && !groups.contains(&g) {
groups.push(g);
}
}
cleared =
raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
if top_root {
cleared.retain(|g| !matches!(*g, "component" | "props"));
}
}
let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
if (!groups.is_empty() || !cleared.is_empty())
&& let Some(m) = doc.node_mut(id)
{
for g in groups {
m.overrides.insert(g.to_string());
}
for g in cleared {
m.overrides.remove(g);
}
}
if (lost || nested_switch)
&& let Some(r) = root_instance(doc, id)
{
resync.insert(r);
}
}
// 2. Components whose layers changed, were added or removed. Value-only edits (paint, text,
// visibility, …) are carried to the copies directly; anything else rebuilds the instances.
let mut comps: BTreeSet<NodeId> = BTreeSet::new();
let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new();
// Main layers that only gained new layers or lost some: their copies take the same change.
let mut restructured: Vec<NodeId> = Vec::new();
let mut covered: BTreeSet<NodeId> = BTreeSet::new();
for &id in changed {
if let (Some(b), Some(n)) = (before.node(id), doc.node(id))
&& b.children != n.children
&& owning_instance(doc, id).is_none()
&& owning_component(doc, id).is_some()
&& let Some(layers) = restructurable(doc, before, b, n)
{
restructured.push(id);
covered.extend(layers);
}
}
for &id in changed {
if covered.contains(&id) || restructured.contains(&id) {
continue;
}
let Some(c) = owning_component(doc, id) else { continue };
let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else {
comps.insert(c);
continue;
};
if b == n {
continue;
}
match patchable(doc, b, n) {
Some(groups) => patches.push((id, groups)),
None => {
comps.insert(c);
}
}
}
for &id in touched {
if !doc.contains(id)
&& !covered.contains(&id)
&& let Some(c) = owning_component(before, id)
&& doc.contains(c)
{
comps.insert(c);
}
}
for r in resync {
if doc.contains(r) {
sync_instance(doc, r);
if let Some(c) = owning_component(doc, r) {
comps.insert(c);
}
}
}
// A copy the patch can't follow exactly rebuilds instead (as does any other change).
let patched = comps.is_empty()
&& !full_rebuild()
&& (restructured.is_empty() || patch_structure(doc, before, &restructured))
&& (patches.is_empty() || patch_copies(doc, &patches));
#[cfg(test)]
if patched && (!restructured.is_empty() || !patches.is_empty()) {
PATCHED.with(|c| c.set(c.get() + 1));
}
if !patched {
comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id)));
}
// 3. Instances of changed components follow; an instance inside another component makes that
// one change too.
rebuild_instances(doc, &comps);
}
#[cfg(test)]
thread_local! {
/// Tests: rebuild instances even for edits that could be patched (to compare both ways).
pub(crate) static FULL_REBUILD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Tests: edits carried to the copies without rebuilding instances.
pub(crate) static PATCHED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn full_rebuild() -> bool {
FULL_REBUILD.with(std::cell::Cell::get)
}
#[cfg(not(test))]
fn full_rebuild() -> bool {
false
}
/// The property groups of a main-component layer edited from `b` to `n` when the edit changed
/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without
/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed.
fn patchable(doc: &Document, b: &Node, n: &Node) -> Option<Vec<&'static str>> {
const VALUES: [&str; 17] = [
"fills",
"strokes",
"effects",
"opacity",
"visible",
"name",
"corners",
"characters",
"textStyle",
"padding",
"gap",
"alignment",
"grids",
"exports",
"interactions",
"size",
"position",
];
if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() {
return None;
}
let groups = diff_groups(b, n);
if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) {
return None;
}
// Exactly those groups (and the layer's own overrides) changed.
let mut probe = n.clone();
for g in &groups {
copy_group(b, &mut probe, g);
}
probe.overrides = b.overrides.clone();
if probe != *b {
return None;
}
// Values a component property sets (on this layer or a main it mirrors) come from instances.
let mut cur = Some(n);
for _ in 0..=MAX_DEPTH {
let Some(x) = cur else { break };
if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) {
return None;
}
cur = x.source.and_then(|s| doc.node(s));
}
Some(groups)
}
/// Copies by the layer they mirror; nested instances swapped to a main also follow it.
fn copies_index(doc: &Document) -> BTreeMap<NodeId, Vec<NodeId>> {
let mut copies: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
for n in doc.nodes() {
let Some(s) = n.source else { continue };
copies.entry(s).or_default().push(n.id);
if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind))
&& component != other
{
copies.entry(*component).or_default().push(n.id);
}
}
copies
}
/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances,
/// components or property bindings in them) or lost layers that were deleted: those layers, which
/// [`patch_structure`] carries to the copies. `None` for anything else.
fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option<Vec<NodeId>> {
let mut probe = n.clone();
probe.children = b.children.clone();
probe.overrides = b.overrides.clone();
if probe != *b {
return None;
}
let mut layers = Vec::new();
for c in n.children.iter().filter(|c| !b.children.contains(c)) {
for d in doc.subtree(*c) {
let x = doc.node(d)?;
let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. });
if before.contains(d) || !plain || !x.prop_refs.is_empty() {
return None;
}
layers.push(d);
}
}
for c in b.children.iter().filter(|c| !n.children.contains(c)) {
if doc.contains(*c) {
return None;
}
layers.extend(before.subtree(*c));
}
Some(layers)
}
/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy,
/// through copies of copies (slots holding an instance's own content keep it). Returns false
/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly.
fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool {
let copies = copies_index(doc);
let mut work: Vec<NodeId> = seeds.to_vec();
let mut steps = 0usize;
while let Some(p) = work.pop() {
steps += 1;
if steps > 10_000_000 {
return false;
}
let Some(pn) = doc.node(p).cloned() else { continue };
for c in copies.get(&p).cloned().unwrap_or_default() {
let Some(cn) = doc.node(c).cloned() else { continue };
if holds_content(&cn) {
continue;
}
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
&& a != b
{
continue;
}
// The copy's layers by the layer of `p` they mirror.
let mut mirrors: BTreeMap<NodeId, NodeId> = BTreeMap::new();
for k in &cn.children {
let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false };
if mirrors.insert(src, *k).is_some() {
return false;
}
}
let mut order = Vec::with_capacity(pn.children.len());
for pc in &pn.children {
if let Some(k) = mirrors.remove(pc) {
order.push(k);
continue;
}
// A layer the copy lacks must be new in this edit, placed as in the main.
let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute);
if before.contains(*pc) || !child_follows_main(doc, c, absolute) {
return false;
}
match copy_layers(doc, *pc, c) {
Some(k) => order.push(k),
None => return false,
}
}
// Copies of layers the main lost.
for (src, k) in mirrors {
if doc.contains(src) || doc.remove(k).is_err() {
return false;
}
}
if doc.node(c).is_some_and(|x| x.children != order)
&& let Some(m) = doc.node_mut(c)
{
m.children = order;
}
work.push(c);
}
}
true
}
/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's
/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]).
fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
let Some(p) = doc.node(parent) else { return false };
let constrains = p.layout.is_none() || absolute;
let resized = sized_own(p)
|| match &p.kind {
NodeKind::Instance { component, .. } => {
doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
}
_ => false,
};
!(constrains && resized) && follows_main_geometry(doc, parent)
}
/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the
/// new copy (the caller places it among `parent`'s layers).
fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option<NodeId> {
let sub = doc.subtree(src);
let map: BTreeMap<NodeId, NodeId> = sub.iter().map(|d| (*d, doc.alloc_id())).collect();
for d in &sub {
let mut n = doc.node(*d)?.clone();
n.id = *map.get(d)?;
n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) };
n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect();
n.source = Some(*d);
n.overrides.clear();
n.prop_refs.clear();
n.annotations.clear();
n.measurements.clear();
insert_linked(doc, n).ok()?;
}
map.get(&src).copied()
}
/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through
/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial
/// patch for the caller to rebuild over) when a copy can't follow exactly.
fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool {
let copies = copies_index(doc);
let text = |g: &str| matches!(g, "characters" | "textStyle");
let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec();
let mut steps = 0usize;
while let Some((p, groups)) = work.pop() {
steps += 1;
if steps > 10_000_000 {
return false;
}
let Some(pn) = doc.node(p).cloned() else { continue };
for c in copies.get(&p).cloned().unwrap_or_default() {
let Some(cn) = doc.node(c) else { continue };
// A swapped nested instance shows its own main, not the layer it stands in for.
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
&& a != b
{
continue;
}
// An instance keeps its own visibility, place, export settings and interactions; its
// name follows the main's unless renamed; a resized one lays its layers out against
// the main's size.
let root = matches!(pn.kind, NodeKind::Component { .. });
if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) {
return false;
}
let gs: Vec<&'static str> = groups
.iter()
.copied()
.filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.collect();
if gs.is_empty() {
continue;
}
let geometry = gs.iter().any(|g| matches!(*g, "size" | "position"));
if geometry && !follows_main_geometry(doc, c) {
return false;
}
// Text content and style share their runs: a copy overriding one keeps its runs when the
// other follows the main (as a rebuild carries them).
let runs = match &cn.kind {
NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()),
_ => None,
};
if let Some(m) = doc.node_mut(c) {
for g in &gs {
copy_group(&pn, m, g);
}
if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) {
t.runs = r;
}
}
if (geometry || gs.iter().any(|g| text(g)))
&& let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size)
&& doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6)
{
figcraft_layout::set_size(doc, c, w, h);
}
work.push((c, gs));
}
}
true
}
/// Whether a copy's size and place are its main layer's: no layer around it inside its instances
/// applies constraints on the way down to it (a resized instance or frame without auto layout, or
/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest.
fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
let chain = scope(doc, id);
chain.windows(2).all(|w| {
let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
let constrains = a.layout.is_none() || below.child_layout.absolute;
if sized_own(a) && constrains {
return false;
}
match &a.kind {
NodeKind::Instance { component, .. } => {
!constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9)
}
_ => true,
}
})
}
/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
/// layer's size, so the layers in it can't take their main layers' places as they are.
fn sized_own(n: &Node) -> bool {
n.overrides.contains("size") || n.overrides.contains("resizing")
}
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
/// outwards), each outermost instance once, inner components first.
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
if comps.is_empty() {
return;
}
// Outermost instances by the components their layers instantiate.
let mut roots_of: BTreeMap<NodeId, BTreeSet<NodeId>> = BTreeMap::new();
for n in doc.nodes() {
if let NodeKind::Instance { component, .. } = &n.kind {
roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id));
}
}
// Affected components, with how deep their dependencies go (an instance inside component X of
// component Y: X follows Y).
let mut level: BTreeMap<NodeId, usize> = comps.iter().map(|c| (*c, 0)).collect();
let mut outer: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
let mut frontier: Vec<NodeId> = comps.iter().copied().collect();
let mut rounds = 0;
while !frontier.is_empty() && rounds < 64 {
rounds += 1;
let mut next = Vec::new();
for c in frontier {
let lc = level.get(&c).copied().unwrap_or(0);
for r in roots_of.get(&c).into_iter().flatten() {
let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r));
if let Some(x) = o
&& x != c
{
let lx = level.entry(x).or_insert(0);
if *lx < lc + 1 {
*lx = lc + 1;
next.push(x);
}
}
}
}
frontier = next;
}
// Instances inside components by level, then the rest (each once).
let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect();
order.sort();
for (_, r) in order {
if doc.contains(r) {
sync_instance(doc, r);
}
}
}
@@ -0,0 +1,249 @@
use super::diff::{diff_groups, variable_groups};
use super::instances::{field_group, path_map};
use super::*;
/// Bounded source and parent lookups while a rebase reads an unchanged document.
pub(super) struct RebaseSources<'a> {
doc: &'a Document,
chains: BTreeMap<NodeId, Option<Vec<NodeId>>>,
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
main_changes: BTreeMap<NodeId, bool>,
}
impl<'a> RebaseSources<'a> {
const PARENTS: usize = 512;
const CACHE: usize = 8192;
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
}
fn chain(&mut self, source: NodeId) -> Option<Vec<NodeId>> {
if let Some(chain) = self.chains.get(&source) {
return chain.clone();
}
let read = || {
let mut chain = Vec::new();
let mut seen = BTreeSet::new();
let mut current = Some(source);
for _ in 0..MAX_DEPTH {
let Some(id) = current else { return Some(chain) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
chain.push(id);
current = n.source;
}
current.is_none().then_some(chain)
};
let chain = read();
if self.chains.len() < Self::CACHE {
self.chains.insert(source, chain.clone());
}
chain
}
/// Validate the complete parent chain, even if `main` was found before a cycle.
fn in_main(&mut self, id: NodeId, main: NodeId) -> Option<bool> {
if let Some(found) = self.parents.get(&(id, main)) {
return *found;
}
let read = || {
let mut current = Some(id);
let mut seen = BTreeSet::new();
let mut found = false;
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(found) };
if !seen.insert(id) {
return None;
}
found |= id == main;
current = self.doc.node(id)?.parent;
}
current.is_none().then_some(found)
};
let found = read();
if self.parents.len() < Self::CACHE {
self.parents.insert((id, main), found);
}
found
}
fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option<NodeId> {
let chain = self.chain(source)?;
if chain.contains(&target) {
return None;
}
let mut member = None;
for id in chain {
if self.in_main(id, main)? && id != main && member.is_none() {
member = Some(id);
}
}
member
}
/// Nearest enclosing instances and their current mains. Slots with own content end scope.
pub(super) fn owners(&self, id: NodeId) -> Option<Vec<(NodeId, NodeId)>> {
let mut owners = Vec::new();
let mut current = self.doc.node(id)?.parent;
let mut seen = BTreeSet::from([id]);
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(owners) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
if holds_content(n) {
return Some(owners);
}
if let NodeKind::Instance { component, .. } = &n.kind {
owners.push((id, *component));
}
current = n.parent;
}
current.is_none().then_some(owners)
}
fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
}
/// A contextual child belongs to the same placed slot as its enclosing instance.
fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
let Some((owner, main)) = owners.first() else { return false };
if self.member(source, *main, target).is_none() {
return false;
}
if self.in_main(source, *main) == Some(true) {
return true;
}
let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
&& self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
}
fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option<NodeId> {
let source = self.doc.node(id)?.source?;
if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
|| !self.valid(source, id, owners)
|| !self.contextual(source, owners)
{
return None;
}
Some(source)
}
fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
for (index, (id, main)) in owners.iter().enumerate() {
if *id == stop {
break;
}
let changed = if let Some(changed) = self.main_changes.get(id) {
*changed
} else {
let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => component != main,
Some(NodeKind::Frame) => true,
_ => false,
});
if self.main_changes.len() < Self::CACHE {
self.main_changes.insert(*id, changed);
}
changed
};
if changed {
return true;
}
}
false
}
}
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
/// the look is kept exactly. Values a component property sets aren't overrides.
pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return };
let mine = path_map(doc, inst, true);
let theirs: BTreeMap<String, NodeId> =
if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() };
let value_groups = |a: &Node, b: &Node| -> Vec<String> {
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
};
let mut updates = Vec::new();
let mut sources = RebaseSources::new(doc);
let root_owners = sources.owners(inst).unwrap_or_default();
let placed_source = sources.placed(inst, &root_owners);
let mut contexts = vec![(inst, component)];
contexts.extend_from_slice(&root_owners);
for (id, path) in &mine {
let Some(n) = doc.node(*id) else { continue };
let owners = sources.owners(*id).unwrap_or_default();
let nearest_main = owners.first().map(|(_, main)| *main);
let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
// A swapped or Frame-to-Instance subtree is compared to its current main by the inner
// rebase. An outer name path must not rebind those children to the old expanded copies.
if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
updates.push((*id, valid_source, n.overrides.clone()));
continue;
}
main = main
.or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
let mut overrides: BTreeSet<String> = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
if let Some(m) = source.and_then(|source| doc.node(source)) {
let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
bound.extend(variable_groups(n, m));
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) {
overrides.insert("component".into());
}
}
updates.push((*id, source, overrides));
}
// A placed nested root compares to its actual slot. A detached root compares to its own main.
let root_base = placed_source.or_else(|| {
let chain = sources.chain(component)?;
(!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
});
let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
(Some(m), Some(n)) => {
let from_variables = variable_groups(n, m);
let mut groups: Vec<String> = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
.filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
.collect();
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
if named {
groups.push("name".into());
}
if placed_source.is_some()
&& matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
{
groups.push("component".into());
}
groups
}
_ => Vec::new(),
};
drop(sources);
for (id, main, overrides) in updates {
if let Some(n) = doc.node_mut(id) {
n.source = main;
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
}
}
if let Some(n) = doc.node_mut(inst) {
n.source = root_base;
// A top-level instance's resizing and modes are its own (a nested one's override its main
// layer's).
n.overrides.retain(|g| {
!matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
});
n.overrides.extend(root_groups);
}
}
+459
View File
@@ -0,0 +1,459 @@
use super::diff::diff_groups;
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
use super::*;
/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested
/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides.
pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap<NodeId, std::sync::Arc<Node>>) {
if depth > MAX_DEPTH {
return;
}
let Some(mut root) = doc.node(inst).cloned() else { return };
let NodeKind::Instance { component, props: root_props } = &root.kind else { return };
let component = *component;
let Some(comp) = doc.node(component).cloned() else { return };
if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) {
// A missing main, or an instance inside its own main (would recurse forever).
return;
}
let mut eff_props = default_props(&comp);
for (k, v) in root_props {
if eff_props.contains_key(k) {
eff_props.insert(k.clone(), v.clone());
}
}
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
// one layer mirrors it) and, only when those don't match, by name path.
let mut old_ids = old_layers(doc, inst);
old_ids.extend(spare.keys().copied());
old_ids.sort();
old_ids.dedup();
let mut old: BTreeMap<NodeId, std::sync::Arc<Node>> = spare;
let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
let mut by_src: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
for i in &old_ids {
let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
if let Some(s) = n.source {
if let Some(p) = n.parent {
by_source.entry((p, s)).or_insert(*i);
}
by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
}
old.insert(*i, n);
}
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
// parent keeps its id and overrides).
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
map.insert(component, inst);
let mut used: BTreeSet<NodeId> = BTreeSet::new();
// Slots holding the instance's own content (new ids), and main layers not copied: the
// default content of those slots.
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
let mut fresh_ids = Vec::new();
for s in &src {
let Some(sn) = doc.node(*s) else { continue };
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
skipped.insert(*s);
continue;
}
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
if found.is_none() {
let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false));
found = mains.get(s).and_then(|p| paths.get(p)).copied();
}
let nid = match found {
Some(o) if used.insert(o) => o,
_ => {
fresh_ids.push(*s);
continue;
}
};
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(m));
}
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
edited.insert(nid);
}
map.insert(*s, nid);
}
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh_ids {
let nid = doc.alloc_id();
map.insert(s, nid);
}
// Build the copies (pre-order: parents first).
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
enclosing.insert(inst, vec![inst]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::new();
inst_props.insert(inst, eff_props);
let mut nested = Vec::new();
let mut new_nodes = Vec::new();
// Text layers showing other text or style than their main layer (to re-measure).
let mut remeasure = Vec::new();
for s in &src {
let Some(orig) = doc.node(*s) else { continue };
let Some(&nid) = map.get(s) else { continue };
let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
let mut n = orig.clone();
n.id = nid;
n.parent = Some(parent);
n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect();
n.source = Some(*s);
n.overrides.clear();
n.prop_refs.clear();
let prev = old.get(&nid);
// Annotations belong to the layer they were added to, not to its copies.
n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default();
n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default();
if let Some(o) = prev {
carry_overrides(o, &mut n, Carry::Values);
keep_laid_out(doc, orig, o, &mut n);
}
if !edited.contains(&nid) {
// No longer a slot in the main: it mirrors the main again.
n.overrides.remove(SLOT);
}
// Component properties: the main layer's bindings use this instance's values; layers of
// nested instances use the nested instance's values (one level per source hop).
let enc = enclosing.get(&parent).cloned().unwrap_or_default();
let mut chain: Vec<&Node> = vec![orig];
while chain.len() < enc.len() {
let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
chain.push(next);
}
for (j, level) in chain.iter().enumerate().rev() {
let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
for (field, prop) in &level.prop_refs {
let g = field_group(field);
if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
continue;
}
if let Some(val) = props.get(prop) {
apply_prop(doc, &mut n, field, val);
}
}
}
if let NodeKind::Instance { component: c, props } = &n.kind {
inst_props.insert(nid, props.clone());
let mut e = enc;
e.push(nid);
enclosing.insert(nid, e);
if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
nested.push(nid);
}
} else {
enclosing.insert(nid, enc);
}
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
&& a != b
{
remeasure.push(nid);
}
new_nodes.push(n);
}
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
// Root: follows the main except its own placement, name, size and overrides.
let order: Vec<NodeId> = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
let mut fresh = comp.clone();
fresh.id = inst;
fresh.parent = root.parent;
fresh.children = order;
fresh.transform = root.transform;
fresh.kind = root.kind.clone();
carry_prop_bindings(&root, &mut fresh);
// A nested instance keeps mirroring its layer of the outer main.
let nested_copy = inside_instance(doc, inst);
fresh.source = if nested_copy { root.source } else { Some(component) };
fresh.constraints = root.constraints;
fresh.child_layout = root.child_layout.clone();
fresh.interactions = root.interactions.clone();
fresh.exports = root.exports.clone();
fresh.prop_refs = root.prop_refs.clone();
fresh.plugin_data = root.plugin_data.clone();
fresh.annotations = root.annotations.clone();
// Code links belong to the main component; instances read them from it.
fresh.code_links.clear();
// An instance's own dev resources (its main's are listed with them, not copied).
fresh.dev_resources = root.dev_resources.clone();
fresh.measurements = root.measurements.clone();
fresh.locked = root.locked;
fresh.visible = root.visible;
fresh.var_modes = root.var_modes.clone();
fresh.is_mask = root.is_mask;
fresh.exposed = root.exposed;
fresh.guides = root.guides.clone();
fresh.dev_status = root.dev_status;
fresh.dev_changed = root.dev_changed;
fresh.dev_note = root.dev_note.clone();
fresh.constrain_proportions = root.constrain_proportions;
// How the instance combines in a boolean group it sits in is its own.
fresh.bool_op = root.bool_op;
// A swapped nested instance keeps the name its layer was given in the outer main.
let renamed = nested_copy
&& root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind {
NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c),
_ => false,
});
fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) };
// Switched to another variant: its own overrides stay where both variants agreed.
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
let differs = diff_groups(prev, &comp);
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
}
carry_overrides(&root, &mut fresh, Carry::Root);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
// Geometry overrides of children win over constraints.
for s in &src {
let Some(nid) = map.get(s).copied() else { continue };
let Some(o) = old.get(&nid) else { continue };
// A frame that kept the size its own resizing gave it: its layers (copied at their main
// layers' places) follow their constraints from its main layer's size.
if o.overrides.contains("resizing")
&& !o.overrides.contains("size")
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
{
figcraft_layout::apply_constraints(doc, nid, mw, mh);
}
if !o.overrides.contains("size") && !o.overrides.contains("position") {
continue;
}
remeasure.push(nid);
let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
if o.overrides.contains("size") {
// Through set_size so vector networks scale with the box.
figcraft_layout::set_size(doc, nid, o.width, o.height);
if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
figcraft_layout::apply_constraints(doc, nid, ow, oh);
}
}
if let Some(n) = doc.node_mut(nid) {
carry_overrides(o, n, Carry::Geometry);
}
}
// Text whose characters or style differ from the main re-measures.
for nid in remeasure {
if let Some(n) = doc.node(nid)
&& let Some((w, h)) = figcraft_text::auto_size(n)
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
{
figcraft_layout::set_size(doc, nid, w, h);
}
}
// Nested instances showing another main than the main's copy (swapped): rebuild from it,
// with the old layers below them this rebuild didn't reuse.
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
for n in nested {
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
.iter()
.filter(|(_, x)| {
let mut cur = x.parent;
for _ in 0..=MAX_DEPTH * 4 {
match cur {
Some(p) if p == n => return true,
Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
None => return false,
}
}
false
})
.map(|(i, x)| (*i, x.clone()))
.collect();
take_placing_overrides(doc, n, &old, &mut below);
sync_instance_depth(doc, n, depth + 1, below);
}
}
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
/// copies just made of the outer main's, and `below`) takes the overrides held along the source
/// chain it had before this rebuild (`before`), through layers inside instances in the mains that
/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a
/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main.
/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value.
fn take_placing_overrides(
doc: &mut Document,
n: NodeId,
before: &BTreeMap<NodeId, std::sync::Arc<Node>>,
below: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
) {
let placing = placing_mains(doc, n);
for id in doc.subtree(n).into_iter().filter(|i| *i != n) {
let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default();
if let Some(layer) = doc.node_mut(id) {
take(layer, &held);
}
}
for layer in below.values_mut() {
let held = held_along(doc, layer, &placing);
if !held.is_empty() {
take(std::sync::Arc::make_mut(layer), &held);
}
}
}
/// The mains placing nested instance `n`: those of the layers it mirrors, outward.
fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet<NodeId> {
let mut out = BTreeSet::new();
let mut cur = doc.node(n).and_then(|x| x.source);
for _ in 0..=MAX_DEPTH {
let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break };
out.extend(owning_component(doc, s.id));
cur = s.source;
}
out
}
/// The overrides but geometry and slot content held along `layer`'s source chain while it runs
/// through layers inside instances in `placing` mains, each with the nearest layer holding it.
fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet<NodeId>) -> BTreeMap<String, std::sync::Arc<Node>> {
let mut out = BTreeMap::new();
let mut cur = layer.source;
for _ in 0..=MAX_DEPTH {
let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break };
if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) {
break;
}
for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) {
out.entry(g.clone()).or_insert_with(|| s.clone());
}
cur = s.source;
}
out
}
/// Give `layer` the `held` groups it has no override of its own for.
fn take(layer: &mut Node, held: &BTreeMap<String, std::sync::Arc<Node>>) {
for (g, from) in held {
if layer.overrides.insert(g.clone()) {
copy_group(from, layer, g);
}
}
}
/// Whether main components `a` and `b` are two variants of one set.
fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
}
/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched
/// variants): `o` without the overrides of properties the two variants' layers differ in (`None`
/// when it keeps them all). Text layers keep every override.
fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option<Node> {
let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?;
if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) {
return None;
}
if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) {
return None;
}
let differs = diff_groups(from, doc.node(s)?);
let mut m = o.clone();
m.overrides.retain(|g| !differs.contains(&g.as_str()));
(m.overrides.len() != o.overrides.len()).then_some(m)
}
/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of
/// its slots): what swapping it from the Assets panel keeps.
pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) {
for d in old_layers(doc, inst).into_iter().chain([inst]) {
let Some(n) = doc.node(d) else { continue };
let text = matches!(n.kind, NodeKind::Text(_));
let keep = |g: &String| text || g == SLOT || (d == inst && g == "size");
if !n.overrides.iter().all(keep)
&& let Some(m) = doc.node_mut(d)
{
m.overrides.retain(keep);
}
}
}
/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old
/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually
/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever
/// something that sizes or places layers did change.
fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) {
if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") {
return;
}
let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
if flows {
// The main's rotation and skew, the old place.
let [a, b, c, d, _, _] = n.transform.as_coeffs();
let [.., e, f] = o.transform.as_coeffs();
n.transform = Affine::new([a, b, c, d, e, f]);
}
// Only layers whose shape is their box (a vector's points scale with it).
let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. });
let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows));
if derived(n.child_layout.sizing_h) {
n.width = o.width;
}
if derived(n.child_layout.sizing_v) {
n.height = o.height;
}
}
/// The layers of instance `inst` a rebuild matches against (every layer below it but the content
/// of slots holding its own), ascending.
fn old_layers(doc: &Document, inst: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
let mut stack = vec![inst];
while let Some(id) = stack.pop() {
let Some(n) = doc.node(id) else { continue };
for c in &n.children {
out.push(*c);
if out.len() > 1_000_000 {
break;
}
if doc.node(*c).is_some_and(|cn| !holds_content(cn)) {
stack.push(*c);
}
}
}
out.sort();
out.dedup();
out
}
/// Insert a node whose parent/children links are already set (used when rebuilding instances).
pub(super) fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> {
let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?;
let id = n.id;
let mut n = n;
let kids = std::mem::take(&mut n.children);
doc.insert(n, parent, None)?;
if let Some(m) = doc.node_mut(id) {
m.children = kids;
}
// The parent may already list us (its children were copied with ids): keep the first.
if let Some(p) = doc.node_mut(parent) {
let mut seen = BTreeSet::new();
p.children.retain(|c| seen.insert(*c));
}
Ok(())
}
@@ -0,0 +1,147 @@
use super::*;
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
/// the left-most).
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
let corner = |c: &NodeId| {
doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x))
};
let kids = &doc.node(set)?.children;
kids.iter()
.copied()
.filter_map(|c| corner(&c).map(|k| (k, c)))
.min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, c)| c)
}
/// Variants of a set sharing one combination of property values (each group two or more, in
/// layer order): a conflict to resolve by giving each a unique combination.
pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec<Vec<NodeId>> {
let mut by: BTreeMap<BTreeMap<String, String>, Vec<NodeId>> = BTreeMap::new();
for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
by.entry(variant_props(&n.name)).or_default().push(c);
}
}
by.into_values().filter(|v| v.len() > 1).collect()
}
/// Parse a variant name "Size=Large, State=Hover" into pairs.
pub fn variant_props(name: &str) -> BTreeMap<String, String> {
name.split(',')
.filter_map(|kv| {
let (k, v) = kv.split_once('=')?;
Some((k.trim().to_string(), v.trim().to_string()))
})
.collect()
}
/// Format variant pairs as a layer name, in the given property order (others after, sorted).
pub fn variant_name(pairs: &BTreeMap<String, String>, order: &[String]) -> String {
let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect();
keys.extend(pairs.keys().filter(|k| !order.contains(k)));
keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::<Vec<_>>().join(", ")
}
/// Variant properties of a set: property → values, in first-seen order.
pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec<String>)> {
let mut out: Vec<(String, Vec<String>)> = Vec::new();
for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
let Some(cn) = doc.node(c) else { continue };
for kv in cn.name.split(',') {
let Some((k, v)) = kv.split_once('=') else { continue };
let (k, v) = (k.trim().to_string(), v.trim().to_string());
match out.iter_mut().find(|(p, _)| *p == k) {
Some((_, vals)) => {
if !vals.contains(&v) {
vals.push(v);
}
}
None => out.push((k, vec![v])),
}
}
}
out
}
/// The variant in `set` matching `wanted` best: all given properties equal, or else the most.
pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap<String, String>) -> Option<NodeId> {
let s = doc.node(set)?;
let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count());
if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) {
return Some(exact);
}
s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0))
}
/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each
/// layer's `source` is the main layer at the same place, swapped nested instances mirror their
/// own main, slots holding the instance's own content aren't compared. Returns the problems found.
pub fn mirror_issues(doc: &Document) -> Vec<String> {
doc.nodes()
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id))
.flat_map(|n| instance_issues(doc, n.id))
.collect()
}
/// [`mirror_issues`] for one instance.
pub(super) fn instance_issues(doc: &Document, id: NodeId) -> Vec<String> {
fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec<String>) {
if depth > MAX_DEPTH || holds_content(inst) {
return;
}
if inst.children.len() != main.children.len() {
out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len()));
return;
}
for (a, b) in inst.children.iter().zip(&main.children) {
let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue };
if an.source != Some(bn.id) {
out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id));
continue;
}
match (&an.kind, &bn.kind) {
(NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => {
if let Some(m) = doc.node(*x) {
walk(doc, an, m, depth + 1, out);
}
}
_ => walk(doc, an, bn, depth + 1, out),
}
}
}
let mut out = Vec::new();
if let Some(n) = doc.node(id)
&& let NodeKind::Instance { component, .. } = &n.kind
&& let Some(m) = doc.node(*component)
{
walk(doc, n, m, 0, &mut out);
}
out
}
/// The nested instance copies inside instance `id` whose main-component counterpart is exposed
/// (top-most first; an exposed instance's own exposed nested instances follow it).
pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
let mut seen = 0usize;
while let Some(c) = stack.pop() {
seen += 1;
if seen > 100_000 {
break;
}
let Some(n) = doc.node(c) else { continue };
// A hidden exposed instance hides its properties too.
let exposed =
n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed);
if exposed {
out.push(c);
}
// Only exposed nested instances pass their own exposed instances up.
if !matches!(n.kind, NodeKind::Instance { .. }) || exposed {
stack.extend(n.children.iter().rev().copied());
}
}
out
}
@@ -439,9 +439,9 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
Ok(PublishedLibrary { manifest, doc: Arc::new(snap) })
}
/// When a library file was last read.
/// When a file was last read: its length and modification time (also a document's own file, #1330).
#[derive(Clone, Debug, PartialEq)]
struct Stamp {
pub(crate) struct Stamp {
len: u64,
modified: Option<std::time::SystemTime>,
}
@@ -545,7 +545,7 @@ fn now() -> Option<std::time::SystemTime> {
}
#[cfg(not(target_arch = "wasm32"))]
fn stat(path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(path: &str) -> std::result::Result<Stamp, String> {
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
@@ -554,7 +554,7 @@ fn stat(path: &str) -> std::result::Result<Stamp, String> {
}
#[cfg(target_arch = "wasm32")]
fn stat(_path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(_path: &str) -> std::result::Result<Stamp, String> {
Err(NO_FILES.into())
}
@@ -1331,5 +1331,4 @@ pub fn published_variables(lib: &PublishedLibrary) -> impl Iterator<Item = &Vari
}
#[cfg(test)]
#[path = "library_tests.rs"]
mod tests;
@@ -212,5 +212,4 @@ impl Session {
}
#[cfg(test)]
#[path = "library_server_tests.rs"]
mod tests;
@@ -245,5 +245,4 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
}
#[cfg(test)]
#[path = "library_swap_tests.rs"]
mod tests;
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"component.playground",
"Component playground",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
+5
View File
@@ -0,0 +1,5 @@
pub mod batch;
pub mod doc_urls;
pub mod file_access;
pub mod inspect;
pub mod params;
+192
View File
@@ -0,0 +1,192 @@
//! Step references in `command.batch`: a step's params can use what an earlier step returned.
//!
//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}`
//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
use std::collections::HashMap;
use serde_json::{Map, Value};
use crate::{Result, bad};
/// Deepest params nesting the resolver walks before giving up.
const MAX_DEPTH: usize = 64;
/// One step of a batch: a command, its params and the name later steps can call it by.
#[derive(Clone, Debug, PartialEq)]
pub struct BatchStep {
pub command: String,
pub params: Value,
/// The step's `as`, for `{"$ref": name}` in later steps.
pub name: Option<String>,
}
impl BatchStep {
/// A step without a name.
pub fn new(command: impl Into<String>, params: Value) -> Self {
Self { command: command.into(), params, name: None }
}
}
/// A step's `as` (`step` is its index, for the error): `None` when it has none.
pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
match json.get("as") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())),
Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))),
}
}
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
#[derive(Clone, Debug, Default)]
pub struct BatchScope {
names: HashMap<String, usize>,
results: Vec<Value>,
}
impl BatchScope {
/// The scope for running `steps`. Two steps with the same name fail here, before any step runs.
pub fn new(steps: &[BatchStep]) -> Result<Self> {
let mut names = HashMap::new();
for (i, step) in steps.iter().enumerate() {
let Some(name) = &step.name else { continue };
if let Some(first) = names.insert(name.clone(), i) {
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
}
}
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
}
/// The results of the steps run so far, in order.
pub fn results(&self) -> &[Value] {
&self.results
}
pub fn into_results(self) -> Vec<Value> {
self.results
}
/// The index of the step that runs next.
pub fn next_step(&self) -> usize {
self.results.len()
}
pub(crate) fn push(&mut self, result: Value) {
self.results.push(result);
}
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
}
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
let step = self.next_step();
if depth > MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o),
Value::Object(o) => {
let mut out = Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), self.resolve_in(x, depth + 1)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for.
fn reference(&self, o: &Map<String, Value>) -> Result<Value> {
let step = self.next_step();
let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}"));
let key = if o.contains_key("$step") { "$step" } else { "$ref" };
if let Some(k) = o.keys().find(|k| *k != key && *k != "field") {
return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`")));
}
let (n, who) = if key == "$step" {
let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?;
let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| {
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
fail(format!("`$step` {n} must be an earlier step ({range})"))
})?;
(n, format!("step {n}"))
} else {
let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?;
let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?;
if n >= step {
return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step")));
}
(n, format!("step {n} (\"{name}\")"))
};
let path = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
Some(_) => return Err(fail("`field` must be a string".into())),
};
let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?;
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
}
fn known_names(&self) -> String {
let mut names: Vec<_> = self.names.iter().collect();
names.sort_by_key(|(_, i)| **i);
if names.is_empty() {
return "no step has an `as` name".into();
}
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
list.join(", ")
}
}
/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in
/// JSON Pointer is allowed). The error is the detail after "has no `path`".
fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
// A key as written first, so a key holding `/` still works as it did before paths.
if let Some(x) = v.get(path) {
return Ok(x);
}
let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" };
walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}"))
}
fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
let mut cur = v;
let mut walked = String::new();
for seg in path.split('/') {
let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") };
cur = match cur {
Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?,
Value::Array(a) => match seg.parse::<usize>() {
Ok(i) => a.get(i).ok_or_else(|| {
let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) };
format!(": {here} has {items}")
})?,
Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")),
},
_ => return Err(format!(": {here} is {}", kind(cur))),
};
if !walked.is_empty() {
walked.push('/');
}
walked.push_str(seg);
}
Ok(cur)
}
fn kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}

Some files were not shown because too many files have changed in this diff Show More