Merge main into #1516

This commit is contained in:
bflatastic committed 2026-10-29 23:58:44 +07:00
commit 86ab39b5cc
842 files changed
+116826 -41136

No files matched your search

+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
+24
View File
@@ -23,3 +23,27 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+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:
+4 -2
View File
@@ -26,8 +26,10 @@ permissions:
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: figcraft
app-dir: FigCraft
install-relative-path: Programs/ArtCraft/FigCraft
install-scope: perUser
+2
View File
@@ -8,3 +8,5 @@ plans/
/log/
.DS_Store
*.profraw
.cache/
.vscode/
+17 -2
View File
@@ -26,8 +26,23 @@ 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. |
## Arabic interface catalog
| Path | Contributor | Source | Licence | Review |
|---|---|---|---|---|
| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending |
+1
View File
@@ -62,6 +62,7 @@ code.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
safety nets, not substitutes for `Result`.
Generated
+897 -150
View File
File diff suppressed because it is too large. Load diff
+50 -10
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -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,24 @@ 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"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# 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" }
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -45,6 +59,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 +78,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 +98,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"
@@ -90,6 +109,9 @@ webbrowser = "1"
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
# Style lints from newer toolchains that fight index-heavy pixel code.
needless_range_loop = "allow"
@@ -122,14 +144,32 @@ codegen-units = 4
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
opt-level = "s"
# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate.
opt-level = "z"
lto = "fat"
codegen-units = 1
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, ArkUI, Flutter) 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.
+82 -12
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-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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
@@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md).
| 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) |
@@ -63,9 +63,9 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| 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) |
| 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); Arabic draft: 2,508 entries, native review and panel mirroring pending | 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 +89,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
@@ -103,18 +103,18 @@ native speakers' reviews of the nine language drafts need people.
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 96% | draft (needs native review) |
| Korean | ko | 95% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
| 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, Vietnamese and Arabic 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,
@@ -127,13 +127,34 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
- **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged.
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. 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** · M6, #1143: 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** · 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.
@@ -143,9 +164,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed
filenames open normally; the global `--no-system-fonts` flag also respects the terminator.
Binary regression coverage opens five such native files and checks their JSON info.
- **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.
@@ -329,9 +355,51 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press more than a few pixels from the edited shape used to leave edit mode at once, so a drag over another layer moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged |
| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged |
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged |
| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged |
| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged |
| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged |
| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). |
| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged |
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged |
| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged |
| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged |
| 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. |
| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged |
| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) |
| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged |
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
| 2026-10-27 | minor | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. |
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. 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 | M6, #1143: 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 | 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 |
@@ -394,6 +462,8 @@ 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 | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List<Widget>?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. 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. |
+13
View File
@@ -20,6 +20,10 @@ impl Args {
let mut bad: Option<CliError> = None;
let mut it = argv.iter();
while let Some(arg) = it.next() {
if arg == "--" {
a.positional.extend(it.cloned());
break;
}
let name = if let Some(n) = arg.strip_prefix("--") {
n
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
@@ -87,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),
+142 -27
View File
@@ -4,14 +4,20 @@ 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};
pub fn load(path: &str) -> CliResult<Document> {
load_noted(path).map(|(d, _)| d)
let (doc, notes) = load_noted(path)?;
if ext(path) == "penpot" {
for note in notes {
eprintln!("note: {note}");
}
}
Ok(doc)
}
/// The document with the loader's notes (repairs of a damaged file, or import warnings).
@@ -25,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,
}
}
@@ -120,7 +126,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
"fig" => fig_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
"penpot" => penpot_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig, penpot)"))),
};
write(out, &bytes)
}
@@ -134,6 +141,14 @@ fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
Ok(bytes)
}
fn penpot_bytes(doc: &Document) -> CliResult<Vec<u8>> {
let (bytes, warnings) = figcraft_engine::cmd::file::penpot_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
for w in warnings {
eprintln!("note: {w}");
}
Ok(bytes)
}
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
@@ -225,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> {
@@ -235,19 +252,28 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
if t.format.as_deref().map_or(ext(out) == "penpot", |f| f.eq_ignore_ascii_case("penpot")) {
write(out, &penpot_bytes(&doc)?)?;
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
let page = page_named(&doc, t.page.as_deref())?;
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)")))?
}
Some(f) => image_format(&f.to_ascii_lowercase())
.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 {
@@ -292,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))
@@ -318,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") => {
@@ -338,12 +390,14 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
.enumerate()
.map(|(i, s)| {
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?;
let params = match s.get("params") {
None | Some(Value::Null) => json!({}),
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()
}
@@ -352,43 +406,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()))?)?;
@@ -413,6 +468,9 @@ mod tests {
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
assert!(parse_script("not json\n").is_err());
assert!(parse_script("42").is_err());
// `args` for `params` would run the step without them (#1413).
let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string();
assert!(e.contains("step 1: unknown key `args`"), "{e}");
}
#[test]
@@ -511,6 +569,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() {
@@ -530,4 +625,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();
}
}
+43 -25
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] [--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>]
@@ -33,11 +33,11 @@ Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): te
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .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); PDFs take print options (a bleed like 3mm or 0.125in,
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
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)
@@ -108,15 +108,27 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
if system_fonts {
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
let flags = [flags, &["no-system-fonts"]].concat();
let args = Args::parse(argv, valued, &flags)?;
if !args.flag("no-system-fonts") {
// 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();
}
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
Ok(args)
}
fn run(argv: &[String]) -> CliResult<ExitCode> {
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
return Err(CliError::Usage("missing subcommand".into()));
};
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
match cmd.as_str() {
"help" | "--help" | "-h" => {
println!("{USAGE}");
@@ -127,21 +139,27 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
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" => {
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
@@ -149,19 +167,19 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"render" => {
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
let a = parse_args(&rest, &["filter"], &["json"])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("commands takes no positional arguments".into()));
}
@@ -170,7 +188,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"mcp" => {
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
@@ -185,7 +203,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
let a = parse_args(&rest, &["server"], &[])?;
match a.positional.as_slice() {
[verb, folder] if verb == "publish" => {
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
@@ -195,10 +213,10 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
}
"perf" => {
let a = Args::parse(rest, &["layers", "instances"], &[])?;
let a = parse_args(&rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances, system_fonts)
perf::run(&sizes, &instances, !a.flag("no-system-fonts"))
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
+66
View File
@@ -13,6 +13,72 @@ fn tmp(name: &str) -> std::path::PathBuf {
d
}
/// Conversion and image commands must report the Penpot loader's notes too.
#[test]
fn penpot_import_notes_reach_convert_export_and_render() {
use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger};
use figcraft_doc::{Document, NodeKind};
let d = tmp("penpot-notes");
let mut doc = Document::new("Prototype");
let page = doc.first_page().unwrap();
let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap();
let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap();
doc.node_mut(home).unwrap().interactions.push(Interaction {
trigger: Trigger::OnClick,
actions: vec![Action::Navigate { dest, preserve_scroll: false }],
transition: Transition::Instant,
duration_ms: 0,
easing: Easing::Linear,
delay_ms: 0,
});
let input = d.join("source.penpot");
std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap();
for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] {
let mut cmd = bin();
cmd.arg(command).arg(&input);
if command != "convert" {
cmd.arg("-o");
}
let out = cmd.arg(d.join(name)).output().unwrap();
let notes = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{command}: {notes}");
assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn option_terminator_allows_dash_prefixed_filenames() {
let dir = tmp("option-terminator");
let doc = figcraft_doc::Document::new("Dash-prefixed file");
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] {
std::fs::write(dir.join(name), &bytes).unwrap();
let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["file"], name);
assert_eq!(info["name"], "Dash-prefixed file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn global_font_flag_respects_option_values() {
for filter in ["--", "--no-system-fonts"] {
let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap();
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
}
}
#[test]
fn global_font_flag_can_precede_the_subcommand() {
let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(!serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).unwrap().is_empty());
}
#[test]
fn bad_arguments_exit_2_without_panicking() {
for args in [
+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);
}
+2 -2
View File
@@ -43,8 +43,8 @@ Options (also keys of the --config TOML file):
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb, max_plugins,
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
max_connections, max_people, compact_after, compact_mb, max_log_mb,
max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish,
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
connects_per_minute, publishes_per_hour, messages_per_second
+11 -6
View File
@@ -64,6 +64,16 @@ fn is_code(shown: &str) -> bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
/// The links file keeps hashes only, never the codes or the token they wrap.
fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 2);
}
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
// On disk: hashes only, of the codes as of the links' tokens.
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 2);
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
// Someone types the edit code, the way people type.
let mut b = app("Bo", &url);
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
+14 -7
View File
@@ -39,6 +39,9 @@ pub struct Config {
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
/// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one
/// further behind catches up with the whole file).
pub max_log_mb: u64,
/// Most plugin versions the plugin directory keeps (0 turns it off).
pub max_plugins: usize,
/// Largest plugin bundle, in MiB.
@@ -84,6 +87,7 @@ impl Default for Config {
max_people: 256,
compact_after: 1000,
compact_mb: 16,
max_log_mb: 64,
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
@@ -209,6 +213,7 @@ impl Config {
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_log_mb", self.max_log_mb),
("max_plugin_mb", self.max_plugin_mb),
("max_plugins_mb", self.max_plugins_mb),
] {
@@ -216,7 +221,7 @@ impl Config {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.file_mb() > 4096 || self.compact_mb > 4096 {
if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
// A bundle travels in one message (at most 64 MiB) next to its entry.
@@ -274,17 +279,19 @@ impl Config {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
/// The collaboration core's part: when open, messages as large as a file at most, and the
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
/// quarter of a file's bytes (256 KiB at least).
/// The collaboration core's part: the batches kept for reconnecting clients (in memory, and
/// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at
/// most, and those batches also at most a quarter of a file's bytes (256 KiB at least).
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX);
let base =
figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
if !self.open_publish {
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base }
}
/// The transport's part. A private server keeps the defaults; open to the public, connections
+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)
}
+31 -27
View File
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
}
}
/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
let mut raw = Raw::connect(addr);
raw.send(&open(edit));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
let mut rng = Rng::new(5);
for round in 0..12 {
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
for _ in 0..1 + rng.below(8) {
let i = rng.below(junk.len());
junk[i] = rng.next_u64() as u8;
}
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
}
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
let mut raw = Raw::connect(addr);
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
}
#[test]
fn publish_enable_update_and_usage() {
let dir = TempDir::new("library");
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
let edit = Invite::parse(&p.edit).unwrap();
let mut raw = Raw::connect(addr);
raw.send(&open(&edit));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
let mut rng = Rng::new(5);
for round in 0..12 {
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
for _ in 0..1 + rng.below(8) {
let i = rng.below(junk.len());
junk[i] = rng.next_u64() as u8;
}
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
}
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
let mut raw = Raw::connect(addr);
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
junk_is_refused(addr, &edit);
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
// Kept on disk: the library is there after the server stops.
+13
View File
@@ -584,6 +584,19 @@ fn files_above_64_mib_fit_every_limit() {
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() {
let log = |cfg: Config| cfg.store().core.max_log_bytes;
assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too");
assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20);
// Open to the public: also at most a quarter of a file (16 MiB unless set).
assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20);
assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20);
for mb in [0, 4097] {
assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}");
}
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
use super::journal::flush_log;
use super::{
Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply,
LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
};
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
}
}
+304
View File
@@ -0,0 +1,304 @@
use super::{
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
};
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) = std::fs::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(())
}
}
+273
View File
@@ -0,0 +1,273 @@
use super::{
FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs,
now, write_json,
};
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(())
}
}
}
+165
View File
@@ -0,0 +1,165 @@
use super::{
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
write_json,
};
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) }
}
+157
View File
@@ -0,0 +1,157 @@
use super::{
Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed,
Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json,
};
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);
+6
View File
@@ -0,0 +1,6 @@
# The browser build (trunk runs cargo from this folder; packaging/web/package.sh). With simd128 the
# glyph rasterizer's SIMD code compiles for wasm SIMD instead of its much larger scalar fallback:
# about 1.6 MB less wasm. Every current browser has wasm SIMD (Safari since 16.4). The plugin
# workspace is built from the repository root, so its modules stay without SIMD for the plugin runtime.
[target.wasm32-unknown-unknown]
rustflags = ["-Ctarget-feature=+simd128"]
+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"] }
+4 -4
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 />
<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 --enable-simd" 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) => {
+15 -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,14 +33,28 @@ 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
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
# Paste reads the clipboard's public.png as is, keeping the screen density a screenshot records.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] }
# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
# same guarded path as the close button); winit's default Quit ends the process through
# `terminate:`, losing unsaved changes without a word (issue #857).
craft-menu = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
+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" {
+78 -11
View File
@@ -6,7 +6,8 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_ui_egui::ControlRequest;
use figcraft_engine::events::{self, EventLog};
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
@@ -21,6 +22,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());
@@ -30,7 +59,7 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.extend(queue.drain(..count));
}
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
pub fn start(port: u16, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -43,7 +72,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
spawn_connection(stream, queue.clone(), ctx.clone(), events.clone(), &mut workers, TIMEOUT);
}
});
if let Err(e) = spawned {
@@ -56,20 +85,36 @@ fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: imp
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
fn spawn_connection(
stream: TcpStream,
queue: Queue,
ctx: CtxSlot,
events: Arc<EventLog>,
workers: &mut Vec<std::thread::JoinHandle<()>>,
timeout: Duration,
) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
return;
}
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
let _ = serve(stream, queue, ctx, timeout);
let _ = serve_with_events(stream, queue, ctx, events, timeout);
}) {
Ok(worker) => workers.push(worker),
Err(e) => log::error!("control connection thread: {e}"),
}
}
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
/// 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.
/// Serve a connection with a private event log (the tests; the app shares its session's log).
#[cfg(test)]
fn serve(out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
serve_with_events(out, queue, ctx, Arc::new(EventLog::default()), timeout)
}
fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(out.try_clone()?);
@@ -95,8 +140,18 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let id = v.get("id").cloned().unwrap_or(Value::Null);
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
// A poll waits on the shared log, not on a frame: it needs no UI thread and no
// request slot, and it works while the window is minimised.
if method == "events.poll" {
let reply = match event_poll(&events, &params) {
Ok(result) => json!({"id": id, "ok": true, "result": result}),
Err(error) => json!({"id": id, "ok": false, "error": error}),
};
writeln!(out, "{reply}")?;
continue;
}
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 +159,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) => {
@@ -133,6 +190,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
Ok(())
}
/// `events.poll {after?, timeoutMs?, topics?, limit?}`: read the shared log, waiting up to 25 s.
fn event_poll(log: &EventLog, params: &Value) -> Result<Value, String> {
let after = params.get("after").and_then(Value::as_u64).unwrap_or(0);
let timeout = params.get("timeoutMs").and_then(Value::as_u64).unwrap_or(20_000).min(25_000);
let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 1000) as usize;
let topics = events::topics(params.get("topics"))?;
let result = log.poll(after, &topics, limit, Duration::from_millis(timeout));
serde_json::to_value(result).map_err(|e| format!("serialize events: {e}"))
}
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;
+139 -5
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\":"] {
@@ -151,12 +198,26 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let mut clients = Vec::new();
for _ in 0..MAX_CONNECTIONS {
let (client, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
spawn_connection(
server,
pending.clone(),
ctx.clone(),
Arc::new(figcraft_engine::events::EventLog::default()),
&mut workers,
Duration::from_secs(5),
);
clients.push(client);
}
assert_eq!(workers.len(), MAX_CONNECTIONS);
let (mut extra, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
spawn_connection(
server,
pending.clone(),
ctx.clone(),
Arc::new(figcraft_engine::events::EventLog::default()),
&mut workers,
Duration::from_secs(5),
);
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
assert_eq!(workers.len(), MAX_CONNECTIONS);
for client in clients {
@@ -168,7 +229,7 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
std::thread::sleep(Duration::from_millis(1));
}
let (mut client, server) = pair();
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
spawn_connection(server, pending, ctx, Arc::new(figcraft_engine::events::EventLog::default()), &mut workers, Duration::from_secs(5));
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
@@ -202,6 +263,39 @@ fn a_client_that_stops_reading_cannot_hold_a_worker() {
worker.join().unwrap().unwrap();
}
#[test]
fn events_poll_is_answered_without_the_ui_queue() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let log = Arc::new(figcraft_engine::events::EventLog::default());
log.push("comment.added", "Doc", None, json!({"commentId": 7}));
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
client.write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":0,\"timeoutMs\":0}}\n").unwrap();
let mut reader = BufReader::new(client);
let r = reply(&mut reader);
assert_eq!(r["id"], 1);
assert_eq!(r["result"]["events"].as_array().unwrap().len(), 1);
assert_eq!(r["result"]["events"][0]["kind"], "comment.added");
assert_eq!(r["result"]["events"][0]["data"]["commentId"], 7);
assert_eq!(r["result"]["cursor"], 1);
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn a_comment_added_in_the_session_is_pollable() {
let mut session = figcraft_engine::Session::new();
let log = session.events.clone();
let id = session.execute("comment.add", &json!({"x": 10, "y": 20, "text": "Fix the padding"})).unwrap()["id"].clone();
let r = event_poll(&log, &json!({"after": 0, "timeoutMs": 0})).unwrap();
assert_eq!(r["events"].as_array().unwrap().len(), 1);
assert_eq!(r["events"][0]["kind"], "comment.added");
assert_eq!(r["events"][0]["data"]["commentId"], id);
}
#[test]
fn a_limit_sized_frame_is_accepted_at_eof() {
let (mut client, server) = pair();
@@ -227,6 +321,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 +359,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();
@@ -292,3 +389,40 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
worker.join().unwrap().unwrap();
}
}
#[test]
fn events_poll_rejects_an_unknown_topic_without_the_ui_queue() {
let (client, server) = pair();
let pending = queue();
let q = pending.clone();
let log = Arc::new(figcraft_engine::events::EventLog::default());
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
let mut reader = BufReader::new(client);
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"topics\":[\"nope\"],\"timeoutMs\":0}}\n").unwrap();
let r = reply(&mut reader);
assert_eq!(r["id"], 1);
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().is_some_and(|e| e.contains("unknown topic")), "{r}");
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn events_poll_reports_reset_for_a_cursor_ahead_of_the_log() {
let (client, server) = pair();
let log = Arc::new(figcraft_engine::events::EventLog::default());
log.push("comment.added", "Doc", None, json!({"commentId": 1}));
let q = queue();
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
let mut reader = BufReader::new(client);
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":2,\"timeoutMs\":0}}\n").unwrap();
let r = reply(&mut reader);
assert_eq!(r["result"]["reset"], true, "{r}");
assert!(r["result"]["events"].as_array().unwrap().is_empty(), "{r}");
assert_eq!(r["result"]["cursor"], 1);
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
+51 -16
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.
@@ -209,7 +234,7 @@ fn main() -> eframe::Result {
}
let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));
if let Some(p) = port {
control_server::start(p, queue.clone(), ctx_slot.clone());
control_server::start(p, queue.clone(), ctx_slot.clone(), fig.session.events.clone());
}
let icon = services::window_icon();
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
@@ -244,14 +269,24 @@ 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));
// winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
// app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
// sends the root window a close request instead, and the unsaved-changes question
// asks just as it does for the close button. Failure keeps winit's menu: the app
// runs, but ⌘Q ends it without asking.
#[cfg(target_os = "macos")]
if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") {
log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}");
}
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),
}))
}),
);
+162 -9
View File
@@ -2,8 +2,8 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
@@ -11,15 +11,20 @@ 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).
recovery: crate::recovery::Shared,
/// The folder of the last file or folder a dialog picked, where the next one starts.
last_folder: Arc<Mutex<Option<PathBuf>>>,
}
pub fn config_dir() -> Option<PathBuf> {
@@ -54,7 +59,33 @@ 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));
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
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;
}
}
@@ -136,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")]
@@ -231,6 +327,9 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
#[cfg(target_os = "macos")]
const MACOS_PASTEBOARD_PNG: &str = "public.png";
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
#[cfg(all(unix, not(target_os = "macos")))]
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
@@ -262,25 +361,66 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
}
}
/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder
/// picked itself.
pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option<PathBuf> {
let folder = if is_folder { Some(path) } else { path.parent() };
folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf)
}
/// The remembered folder, unless it is gone since: then the system's own choice.
pub(crate) fn start_folder(last: Option<PathBuf>) -> Option<PathBuf> {
last.filter(|f| f.is_dir())
}
impl Desktop {
/// A dialog that starts in the last folder used. The Linux portal does not remember it between
/// dialogs as macOS and Windows do.
fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog {
let dialog = dialog(title);
match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) {
Some(folder) => dialog.set_directory(folder),
None => dialog,
}
}
}
/// `pick`, with the folder of what it picks kept in `last` for the next dialog.
pub(crate) fn remembering(last: Arc<Mutex<Option<PathBuf>>>, pick: DialogFuture, is_folder: bool) -> DialogFuture {
Box::pin(async move {
let path = pick.await?;
if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) {
*last = Some(folder);
}
Some(path)
})
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
let mut dialog = dialog(title).set_file_name(name);
let mut dialog = self.dialog_in_last_folder(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
let pick = picked(dialog.save_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
let pick = picked(self.dialog_in_last_folder(title).pick_folder());
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())
}
@@ -301,6 +441,12 @@ impl Services for Desktop {
}
Some((w, h, img.bytes.into_owned()))
}
#[cfg(target_os = "macos")]
fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
let board = objc2_app_kit::NSPasteboard::generalPasteboard();
let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?;
(png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec())
}
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
@@ -324,6 +470,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()
}
+25
View File
@@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself.
#[test]
fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
use crate::services::{folder_to_remember, remembering, start_folder};
use std::path::PathBuf;
let dir = root("last-folder");
let file = dir.join("Poster.figcraft");
assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder");
assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself");
assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder");
let last = Arc::new(Mutex::new(None::<PathBuf>));
let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false);
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file)));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered");
let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false);
assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder");
assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone()));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
}
+11 -4
View File
@@ -2,8 +2,15 @@
//! 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.";
const DRIVER_HINT: &str = if cfg!(windows) {
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
} else {
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
};
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
@@ -15,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!(text.contains("graphics driver"), "{text}");
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains("unsetting"), "{text}");
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
}
#[cfg(windows)]
@@ -30,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!(text.contains("unsetting"), "{text}");
assert!(!text.contains("graphics driver"), "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}");
}
+4
View File
@@ -4,3 +4,7 @@ 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)" },
]
cognitive-complexity-threshold = 30
+19
View File
@@ -72,7 +72,26 @@ 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"
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
[people.culiulemon]
sync_github_name = true
[people.StrajnarFilip]
display_name = "Filip Strajnar"
+8 -2
View File
@@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let mut late = session("Cy");
late.execute("collab.join", &json!({"code": shown})).unwrap();
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
// The port is free again: another program's host may have taken it, and that host refuses
// the code's secret.
let label = late.live().status.label();
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
}
#[test]
@@ -103,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.
+23 -18
View File
@@ -2,9 +2,10 @@
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every
//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with
//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the
//! WebSocket's events and wakes it at [`Driver::deadline`].
//!
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
@@ -36,13 +37,15 @@ struct Conn {
opened: bool,
welcomed: bool,
seen: Seen,
last_sent: Instant,
/// The last ping (or the connection's start): one goes out every keep-alive interval, whatever
/// else is sent, since presence gets no answer and only what the host sends counts as heard.
last_ping: Instant,
last_heard: Instant,
}
impl Conn {
fn new(now: Instant) -> Self {
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
}
}
@@ -106,7 +109,7 @@ impl<S: Socket> Driver<S> {
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
None => hello(&self.cfg, &self.resume),
};
if !self.write(&hello, now) {
if !self.write(&hello) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -173,7 +176,7 @@ impl<S: Socket> Driver<S> {
return;
}
if let Some(p) = self.throttle.offer(p, now)
&& !self.write(&ClientMsg::Presence(p), now)
&& !self.write(&ClientMsg::Presence(p))
{
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -186,7 +189,7 @@ impl<S: Socket> Driver<S> {
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
return;
}
if !self.write(&msg, now) {
if !self.write(&msg) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -204,7 +207,7 @@ impl<S: Socket> Driver<S> {
let l = &self.cfg.limits;
let conn = self.conn.as_ref().map(|c| {
if c.opened {
[Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
[Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
} else {
[Some(c.started + self.cfg.connect_timeout), None, None]
}
@@ -223,7 +226,7 @@ impl<S: Socket> Driver<S> {
}
let l = self.cfg.limits;
let Some(c) = self.conn.as_ref() else { return };
let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
@@ -233,21 +236,23 @@ impl<S: Socket> Driver<S> {
if now >= last_heard + l.idle_timeout {
return self.end(Outcome::Retry("the host stopped answering".into()), now);
}
let ok = match self.throttle.take_due(now) {
Some(p) => self.write(&ClientMsg::Presence(p), now),
None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
None => true,
};
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
if !ok {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
/// Send a ping; false when it couldn't go.
fn ping(&mut self, now: Instant) -> bool {
if let Some(c) = self.conn.as_mut() {
c.last_sent = now;
c.last_ping = now;
}
self.write(&ClientMsg::Ping)
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
}
+34 -2
View File
@@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() {
d.tick(due);
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
let n = d.sock.sent.len();
// Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
// A ping every keep-alive interval from the connection's start, presence or not (the host
// answers, which counts as traffic).
let ping_at = d.deadline().unwrap();
assert_eq!(ping_at, due + Duration::from_secs(15));
assert_eq!(ping_at, t0 + Duration::from_secs(15));
d.tick(ping_at - ms(1));
assert_eq!(d.sock.sent.len(), n, "not yet");
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
@@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() {
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
}
/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the
/// pings still go out, and the host's pongs keep the connection (before, each presence put the
/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection).
#[test]
fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
let t0 = Instant::now();
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
inbox.drain();
let mut answered = 0;
for i in 1..=900u32 {
let now = t0 + ms(100 * u64::from(i));
d.presence(cursor(f64::from(i)), now);
while let Some(due) = d.deadline().filter(|t| *t <= now) {
d.tick(due);
}
// The host answers every ping (and nothing else) at once.
let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping);
answered = d.sock.sent.len();
if pinged {
d.message(&encode(&ServerMsg::Pong), now);
}
}
assert!(!d.done());
assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected");
assert!(statuses(&inbox.drain()).is_empty());
let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count();
assert!(pings >= 5, "a ping every 15 s ({pings})");
}
#[test]
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
let t0 = Instant::now();
+15 -12
View File
@@ -1,7 +1,8 @@
//! Joining a session: the client end of the transport, with reconnect.
//!
//! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a
//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
//! second, latest wins; a ping every keep-alive interval) while a reader thread takes in what the
//! host sends.
//! When the connection drops after having been live, it reconnects with growing pauses; a
//! refusal or the host ending the session is final.
//!
@@ -280,13 +281,15 @@ fn session(
}
}
};
let mut last_sent = Instant::now();
// A ping every keep-alive interval, whatever else is sent: presence gets no answer, and the
// reader counts only what the host sends.
let mut last_ping = Instant::now();
let hello = hello(cfg, resume);
let mut welcomed = false;
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
let outcome = if send(&mut writer, &hello, &mut last_sent) {
let outcome = if send(&mut writer, &hello) {
loop {
let ping_at = last_sent + cfg.limits.keepalive;
let ping_at = last_ping + cfg.limits.keepalive;
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
let mut out = None;
let mut now_send = Vec::new();
@@ -312,16 +315,17 @@ fn session(
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
Err(RecvTimeoutError::Timeout) => {}
}
if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) {
if !now_send.iter().all(|m| send(&mut writer, m)) {
break Outcome::Retry("the connection dropped".into());
}
let now = Instant::now();
let out = out.or_else(|| throttle.take_due(now));
let ok = match out {
Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent),
None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent),
None => true,
};
let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
let ok = ok
&& (now < last_ping + cfg.limits.keepalive || {
last_ping = now;
send(&mut writer, &ClientMsg::Ping)
});
if !ok {
break Outcome::Retry("the connection dropped".into());
}
@@ -335,8 +339,7 @@ fn session(
}
/// Send one message; false when the connection is gone.
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg, last_sent: &mut Instant) -> bool {
*last_sent = Instant::now();
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
}
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
use super::limits::Admission;
use super::{Access, Admin, AdminReply, ClientMsg, HostInfo, IpAddr, LibraryReply, LibraryReq, PluginReply, PluginReq, Server};
/// 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;
}
+408
View File
@@ -0,0 +1,408 @@
use super::hosting::{Open, spawn};
use super::limits::{PerAddress, ip_key};
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
use super::{
Arc, AtomicBool, AtomicUsize, Bucket, Bytes, ClientMsg, CloseCode, CloseFrame, Duplex, Duration, ErrorResponse, Instant, IpAddr, Message, Meter,
NetLimits, Ordering, Read, Readers, Receiver, RecvTimeoutError, Request, Response, Role, Sender, ServerMsg, SocketAddr, TcpListener, TcpStream,
WebSocket, Write, config, io, message, shutdown, sync_channel,
};
/// 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,
}
}
}
+204
View File
@@ -0,0 +1,204 @@
use super::backend::Backend;
use super::conn::{Shared, accept};
use super::limits::{PerAddress, loopback};
use super::queue::{Blocks, HubIn, InFlight};
use super::{
Arc, AtomicBool, AtomicUsize, ClientMsg, Duration, HashMap, Hub, Inbox, Instant, JoinHandle, MAX_NAME, NetLimits, Ordering, Presence, Readers,
Room, Sender, Server, SocketAddr, TcpListener, TcpStream, channel, io,
};
/// 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::{Access, Arc, HashMap, IpAddr, Ipv6Addr, Mutex, PoisonError, SocketAddr};
/// 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);
}
}
}
}
+212
View File
@@ -0,0 +1,212 @@
use super::limits::ip_blocks;
use super::{
Arc, AtomicUsize, Bytes, ClientMsg, CloseCode, HashMap, IpAddr, Mutex, Ordering, PoisonError, Presence, SyncSender, TcpStream, TrySendError,
};
/// 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,
}
+21 -1
View File
@@ -24,7 +24,13 @@ pub struct NetLimits {
pub hello_timeout: Duration,
/// A connection with no traffic for this long is dead.
pub idle_timeout: Duration,
/// A quiet client pings this often (keeps it under `idle_timeout`).
/// 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 client pings this often, whatever else it sends (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
pub queue: usize,
@@ -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);
}
}
+40 -15
View File
@@ -488,6 +488,21 @@ fn silent_connections_time_out() {
host.stop();
}
#[test]
fn connections_without_a_hello_are_told_why_they_end() {
// A socket's read timeout can end a moment before the hello's deadline; the connection still
// gets the refusal. Twelve at once, so that happens to some of them.
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
std::thread::scope(|s| {
let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect();
for w in waits {
assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time"));
}
});
host.stop();
}
#[test]
fn guests_reconnect_after_a_network_failure() {
let (mut host, mut hp, invite) = start();
@@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
let (mut host, mut hp, invite) = start_with(limits, Limits::default());
let probe = Probe::new();
let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into()));
cfg.limits = limits;
// The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds
// the wait for the welcome, which a loaded machine can take longer than 600 ms to send.
cfg.limits.keepalive = limits.keepalive;
let _g = Guest::start(cfg, probe.inbox.clone()).unwrap();
let mut gp = probe;
gp.wait("welcome", welcomed);
@@ -672,34 +689,42 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
}
/// How long a connection that never says hello stays open while `poke` sends it something every
/// 300 ms (told whether it's the first time); `None`: still open after 6 s.
/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own
/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close
/// and a slow poke loop don't delay it.
fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option<Duration> {
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut r = Raw::connect(addr);
let mut watch = r.0.get_ref().try_clone().unwrap();
let t0 = Instant::now();
let (ended_tx, ended) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
let mut buf = [0u8; 1024];
let until = Instant::now() + WAIT;
loop {
match watch.read(&mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {}
Err(_) => break,
}
}
let _ = ended_tx.send(Instant::now());
});
let (mut closed, mut first) = (None, true);
while t0.elapsed() < Duration::from_secs(6) {
if !poke(&mut r, std::mem::take(&mut first)) {
closed = Some(t0.elapsed());
break;
}
std::thread::sleep(Duration::from_millis(300));
// Drain what the host sent back (pongs, the close); an error means it hung up.
r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap();
match r.0.read() {
Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
closed = Some(t0.elapsed());
break;
}
Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
closed = Some(t0.elapsed());
break;
}
_ => {}
if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) {
closed = Some(at.saturating_duration_since(t0));
break;
}
}
host.stop();
let _ = reader.join();
closed
}
+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)
}
+18 -10
View File
@@ -195,17 +195,25 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
let n = loop {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
}
self.read.set_read_timeout(Some(left))?;
}
self.read.set_read_timeout(Some(left))?;
}
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}?;
let read = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
};
match read {
// The socket's timeout can run out a moment before the deadline (it counts whole
// milliseconds): wait out the rest, so a read times out only once it has passed.
Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
read => break read?,
}
};
if let Some(m) = self.meter.as_mut() {
m.add(n)?;
}
+10 -1
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");
@@ -123,8 +126,14 @@ fn two_files_are_hosted_and_joined_independently() {
host.execute("doc.switch", &json!({"index": 0})).unwrap();
assert_eq!(host.live_summary()["link"], a["link"]);
assert_eq!(host.live_summary()["document"], 0);
// 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.
assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front");
let in_a = rect(&mut host);
host.execute("doc.switch", &json!({"index": 1})).unwrap();
let in_b = rect(&mut host);
+1
View File
@@ -17,6 +17,7 @@ chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
figcraft-color = { workspace = true }
+20 -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.
@@ -241,7 +245,13 @@ pub enum Entry {
/// [`DevSnapshot`](figcraft_doc::DevSnapshot)).
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
/// A key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256
/// hex `figcraft_format::image_hash` produces for the bytes; every key must be a plain
/// archive member name ([`figcraft_doc::ImageData::is_valid_key`]), and dimensions and byte
/// size are bounded as the entry applies (`image_of` in `docprops.rs`).
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
@@ -303,6 +313,13 @@ impl DocProp {
}
}
impl Change {
/// Whether this puts or removes an image entry.
pub(crate) fn is_image(&self) -> bool {
matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
}
}
impl Entry {
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
/// session carry their creator's client id, ids from before and ids of other forms are in
@@ -315,7 +332,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 +345,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 +356,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");
+62 -2
View File
@@ -131,9 +131,10 @@ fn document_level_data_round_trips() {
x: 1.0,
y: 2.0,
anchor: None,
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] },
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![], agent: false },
resolved: false,
replies: vec![],
// An AI agent's reply reaches the others with its name and marker.
replies: vec![Message { agent: true, ..Message::new("Claude (agent)", "Done", 2) }],
read_by: None,
});
b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 });
@@ -250,3 +251,62 @@ fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
let mut r = a.clone();
assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
}
/// Where `id` goes among `kids`, as placement worked before it searched: after every other sibling
/// that sorts before it by `(key, id)`, counted one by one.
fn place_counting(kids: &mut Vec<NodeId>, keys: &BTreeMap<NodeId, String>, id: NodeId) {
kids.retain(|c| *c != id);
let key = |c: NodeId| keys.get(&c).map_or("", String::as_str);
let at = kids.iter().filter(|c| (key(**c), **c) < (key(id), id)).count();
kids.insert(at, id);
}
#[test]
fn many_sibling_placements_keep_the_order_of_counting_them() {
// The reference counts, so it's quadratic: placing 20,000 here took 20 s before the search
// and 0.3 s after (debug build).
const N: u64 = 5_000;
let mut doc = Document::new("Order");
let page = doc.first_page().unwrap();
let parents = [page, doc.create(page, NodeKind::Frame, "A", 0.0, 0.0, 10.0, 10.0).unwrap()];
let mut r = Replica::new(doc);
let mut rng = crate::sim::Rng::new(1065);
// Few whole keys and the fractions between them: many ties (broken by id), many fractions.
let whole = crate::frac::n_between(None, None, 12).unwrap();
let mut pool = whole.clone();
pool.extend(whole.windows(2).map(|w| crate::frac::between(Some(&w[0]), Some(&w[1])).unwrap()));
let mut keys: BTreeMap<NodeId, String> = parents.iter().map(|p| (*p, r.key(*p).to_string())).collect();
let mut kids: BTreeMap<NodeId, Vec<NodeId>> = parents.iter().map(|p| (*p, r.document().node(*p).unwrap().children.clone())).collect();
let mut parent_of = BTreeMap::new();
// One batch of creations in a scrambled id order, under either parent.
let mut creates = Vec::new();
for i in 0..N {
let (id, p, key) = (NodeId(1_000_000 + (i * 7919) % N), parents[rng.below(2)], pool[rng.below(pool.len())].clone());
keys.insert(id, key.clone());
parent_of.insert(id, p);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
creates.push(Change::Create { id, kind: Json::of(&NodeKind::Rectangle).unwrap(), parent: Some(p), position: key, props: vec![] });
}
// Then a batch of moves: to another key (often a tie), or under either parent (its own too).
let mut moves = Vec::new();
for _ in 0..N / 2 {
let id = NodeId(1_000_000 + rng.below(N as usize) as u64);
if rng.chance(0.5) {
let key = pool[rng.below(pool.len())].clone();
keys.insert(id, key.clone());
place_counting(kids.get_mut(&parent_of[&id]).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Position, value: Json::of(&key).unwrap() });
} else {
let p = parents[rng.below(2)];
let old = parent_of.insert(id, p).unwrap();
kids.get_mut(&old).unwrap().retain(|c| *c != id);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Parent, value: Json::of(&Some(p)).unwrap() });
}
}
r.apply(&creates, Mode::Strict).unwrap();
r.apply(&moves, Mode::Strict).unwrap();
for p in parents {
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
}
}
+75 -3
View File
@@ -8,6 +8,7 @@ use base64::Engine as _;
use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use crate::Error;
use crate::change::{Change, DocProp, Entry, Family, Json};
@@ -164,13 +165,68 @@ struct ImageValue {
bytes: String,
}
/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no
/// image this app realistically opens reaches it — an entry claiming a bigger side is
/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it
/// couldn't probe (`image_data`).
pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535;
/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces.
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that
/// already holds an image can't be given other bytes. A key always names the same bytes
/// (producers key images by their bytes; the REST importer's placeholder keys always hold the
/// same grey picture), so nobody can swap the picture under a key someone else added.
fn image_for(held: Option<&ImageData>, key: &str, value: &Json) -> Result<ImageData, Error> {
let img = image_of(key, value)?;
if held.is_some_and(|old| old.bytes != img.bytes) {
return Err(Error::Invalid(format!("image: {key} already holds other bytes")));
}
Ok(img)
}
/// The JSON value an image entry carries: its size and its encoded bytes in base64.
fn image_json(img: &ImageData) -> Result<Json, Error> {
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
}
fn image_of(value: &Json) -> Result<ImageData, Error> {
/// The image a change carries, validated as a collaborator's claim (an invalid entry
/// rejects its batch, leaving every replica's store clean): a key that can name an archive
/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/<key>`), dimensions within
/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read;
/// the encoded length is capped before decoding so hostile text can't force a large
/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must
/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash`
/// writes — a poisoned content-addressed store would show the wrong image everywhere at once
/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders)
/// claim no content address, so only the name and size rules apply to them.
fn image_of(key: &str, value: &Json) -> Result<ImageData, Error> {
image_within(key, value, ImageData::MAX_BYTES)
}
/// [`image_of`] with `max_bytes` for the decoded size.
pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result<ImageData, Error> {
if !ImageData::is_valid_key(key) {
return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)")));
}
let v: ImageValue = value.read("image")?;
if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE {
return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE)));
}
let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4);
if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded {
return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes")));
}
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len())));
}
if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) {
return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to")));
}
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
}
@@ -211,6 +267,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 +308,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
@@ -283,9 +342,12 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
doc.dev_snapshots.insert(*k, Arc::new(snap));
}
Entry::Image(k) => {
let img = image_of(value)?;
let img = image_for(doc.images.get(k), k, 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),
@@ -296,8 +358,16 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
doc.dev_snapshots.remove(k);
}
Entry::Image(k) => {
// An image a layer or style shows stays: removing it would let other bytes go
// under its key, swapping the picture that layer shows ([`image_for`]).
if figcraft_doc::used_images(doc).contains(k) {
return Err(Error::Invalid(format!("image: {k} is still in use")));
}
doc.images.remove(k);
}
Entry::Ballot(k) => {
doc.votes.remove(k);
}
},
_ => return Err(Error::Invalid("not a document-level change".into())),
}
@@ -334,6 +404,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 +418,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),
}
}
+48
View File
@@ -6,6 +6,7 @@ use figcraft_doc::{Collection, Comment, Document, Message, Mode, Style, StyleKin
use crate::change::{Change, Entry, Json};
use crate::message::{ClientMsg, ServerMsg};
use crate::server::NO_SESSION_SPACE;
use crate::sim::Sim;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
@@ -92,6 +93,53 @@ fn client_ids_skip_spaces_used_by_styles_and_comments() {
assert_eq!(join(&mut server).id(), 10);
}
#[test]
fn an_id_in_the_top_space_does_not_block_joins() {
let mut d = Document::new("a");
d.styles.push(style(format!("{STYLE_ID_PREFIX}{}", u64::MAX), "Out of range"));
let c = comment(u64::from(u32::MAX) << 32, &d);
d.comments.push(c);
let mut server = Server::new(d, "", Limits::default());
let a = join(&mut server);
assert!(a.id() > 0 && a.id() < u32::MAX, "{}", a.id());
// After a reload the session still takes newcomers and keeps the document as it was.
let state = server.state().unwrap();
let mut restored = Server::restore(state.clone(), "", Limits::default()).unwrap();
let b = join(&mut restored);
assert!(b.id() > a.id() && b.id() < u32::MAX, "{}", b.id());
assert_eq!(restored.document().styles.len(), 1);
assert_eq!(restored.document().comments.len(), 1);
// A state saved while the counter was stuck at the top recovers too.
let stuck = crate::SessionState { next_client: u32::MAX, ..state };
let mut restored = Server::restore(stuck, "", Limits::default()).unwrap();
let c = join(&mut restored);
assert!(c.id() > a.id() && c.id() < u32::MAX, "{}", c.id());
// A new entry may not take an id from the reserved top half of the spaces.
for (k, space) in [NO_SESSION_SPACE, u32::MAX].into_iter().enumerate() {
let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1);
let ServerMsg::Reject { reason, .. } = send(&mut restored, c.id(), 1 + k as u64, vec![put_style(id.clone())]) else {
panic!("accepted {id}")
};
assert!(reason.contains("id space"), "{reason}");
}
}
#[test]
fn styles_imported_from_another_session_are_accepted_and_joins_go_on() {
let mut server = Server::new(Document::new("a"), "", Limits::default());
let a = join(&mut server);
// `file.import` copies styles with their ids, made by clients this session hasn't given out
// (up to the highest space a member may use).
for (k, space) in [a.id() + 1, a.id() + 1000, NO_SESSION_SPACE - 1].into_iter().enumerate() {
let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1);
assert!(matches!(send(&mut server, a.id(), 1 + k as u64, vec![put_style(id.clone())]), ServerMsg::Changes(_)), "refused {id}");
}
// After a restart client ids start above them, and there are plenty left.
let mut restored = Server::restore(server.state().unwrap(), "", Limits::default()).unwrap();
assert_eq!(join(&mut restored).id(), NO_SESSION_SPACE);
assert_eq!(join(&mut Server::new(restored.document().clone(), "", Limits::default())).id(), NO_SESSION_SPACE);
}
/// Each client makes `rounds` styles, collections (with an extra mode), variables and comments
/// before seeing anyone else's.
fn create_everything(doc: &mut Document, who: usize, round: usize) {
+1 -11
View File
@@ -311,17 +311,7 @@ impl Step {
apply_all(&mut work, &mut out, deletes);
// Changes that apply one by one can still fail the batch's checks together (rare): keep
// only those that pass them in order.
if view.clone().apply(&out, Mode::Strict).is_err() {
let mut w = view.clone();
out.retain(|c| {
let mut t = w.clone();
let ok = t.apply(std::slice::from_ref(c), Mode::Strict).is_ok();
if ok {
w = t;
}
ok
});
}
view.clone().apply_kept(&mut out, |_| true)?;
Ok(out)
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Image entries are checked as they apply: bytes carried under a content-hash key must be
//! exactly what that key hashes to (lowercase sha256, the form `figcraft_format::image_hash`
//! produces), a side stays within the 65 535 px bound the implementation enforces and decoded
//! bytes within its 32 MiB cap. Without this a collaborator could poison the shared
//! content-addressed image store or balloon memory (#1086).
use base64::Engine as _;
use figcraft_doc::Document;
use serde_json::json;
use sha2::{Digest as _, Sha256};
use crate::change::{Change, Entry, Json};
use crate::docprops::apply as apply_doc;
use crate::docprops::{MAX_IMAGE_SIDE, image_within};
/// The side bound the implementation enforces.
const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
/// sha256 hex, computed here independently of the implementation under test.
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// An image entry putting `bytes` (`width` × `height`) under `key`.
pub(crate) fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
Change::Put {
entry: Entry::Image(key.into()),
value: Json(json!({
"width": width,
"height": height,
"bytes": base64::engine::general_purpose::STANDARD.encode(bytes),
})),
}
}
/// Bytes a collaborator hashed into the key, and different bytes they send under it.
pub(crate) fn forged() -> (String, &'static [u8]) {
(sha256_hex(b"the image the key actually names"), b"someone else's bytes")
}
#[test]
fn bytes_that_do_not_hash_to_their_content_hash_key_are_refused() {
let (key, bytes) = forged();
let mut doc = Document::new("t");
let e = match apply_doc(&mut doc, &put(&key, 2, 1, bytes)) {
Ok(_) => panic!("bytes that don't hash to their key must be refused"),
Err(e) => e,
};
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
assert!(doc.images.is_empty(), "the image store was poisoned under key {key}");
}
/// An uppercase key looks like a content hash but isn't one: producers store the lowercase
/// form, so bytes matching under the uppercase form would file a second copy under a name
/// no honest lookup reaches. Refused (#1086).
#[test]
fn an_uppercase_content_hash_shaped_key_is_refused() {
let bytes = b"honestly keyed, uppercase claim".as_slice();
let mut doc = Document::new("t");
let upper = sha256_hex(bytes).to_uppercase();
let e = match apply_doc(&mut doc, &put(&upper, 2, 1, bytes)) {
Ok(_) => panic!("an uppercase key claims a content address it doesn't have"),
Err(e) => e,
};
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
assert!(doc.images.is_empty(), "the store took an uppercase-keyed copy");
}
#[test]
fn an_image_that_hashes_to_its_key_applies() {
let bytes = b"a real image, honestly keyed".as_slice();
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(bytes), 2, 1, bytes)).expect("an honest image must apply unchanged");
let img = doc.images.get(&sha256_hex(bytes)).expect("the image is stored");
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(img.bytes.as_slice(), bytes);
}
#[test]
fn absurd_dimensions_are_refused() {
let bytes = b"honest bytes, dishonest size".as_slice();
let key = sha256_hex(bytes);
for (w, h) in [(MAX_SIDE + 1, 10), (10, MAX_SIDE + 1), (u32::MAX, u32::MAX)] {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&key, w, h, bytes))
.err()
.unwrap_or_else(|| panic!("a {w}×{h} image must be refused, but it applied to {:?} keys", doc.images.len()));
assert!(doc.images.is_empty());
}
}
/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked
/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps
/// the test light.
#[test]
fn oversized_image_bytes_are_refused() {
let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)});
let at = vec![0u8; 1024];
image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies");
let past = vec![0u8; 1025];
assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap");
let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)});
let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}");
}
#[test]
fn a_zero_sized_image_still_applies() {
// The format crate stores 0×0 for bytes it can't probe (`image_data` in `figcraft_format`),
// so a shared document can legitimately carry one; the bytes still have to be honest.
let bytes = b"undecodable bytes kept from a file load".as_slice();
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(bytes), 0, 0, bytes)).expect("0×0 means the size is unknown, not absurd");
assert!(doc.images.contains_key(&sha256_hex(bytes)));
}
#[test]
fn a_key_that_is_not_a_content_hash_shape_still_applies() {
// Legacy producers key images without sha256: the svg importer stores FNV-128 keys (32 hex
// chars, re-keyed by sha256 on save), and the REST importer keeps a placeholder under the
// imageRef itself. They are not content-addressed references, so no hash is claimed; the
// dimension and size bounds still hold.
for key in ["rest-image-missing", "hash", &"f".repeat(32), &"ab".repeat(20)] {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).unwrap_or_else(|e| panic!("legacy key {key:?} refused: {e}"));
assert!(doc.images.contains_key(key));
let mut big = Document::new("t");
assert!(apply_doc(&mut big, &put(key, MAX_SIDE + 1, 1, b"placeholder bytes")).is_err(), "a legacy key must still bound dimensions");
}
}
/// A key becomes an archive member name when the file is saved (`images/<key>`), so a key that
/// isn't plain letters and digits is refused as it applies: a collaborator could otherwise write
/// `images/../../x` into everyone's saved file, which the next load drops as damaged (#1086).
#[test]
fn a_key_that_cannot_name_an_archive_member_is_refused() {
for key in ["../../evil", "a/b", "x.png", "", "with space", "\\server", &"a".repeat(129)] {
let mut doc = Document::new("t");
assert!(apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).is_err(), "key {key:?} must be refused");
assert!(doc.images.is_empty(), "key {key:?} reached the image store");
}
}
/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a
/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session.
#[test]
fn an_image_as_large_as_an_import_takes_still_applies() {
let bytes = vec![0u8; 40 << 20];
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read");
assert_eq!(doc.images.len(), 1);
}
/// An image key is content-derived by every producer, so its bytes never change: a collaborator
/// can't swap the picture under a key another one added.
#[test]
fn an_image_cannot_be_replaced_under_its_key() {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key");
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)");
}
/// A snapshot written before these checks may hold an image they refuse: the session still
/// opens, without that image, instead of not opening at all.
#[test]
fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() {
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
let (key, bytes) = forged();
snapshot.push(put(&key, 2, 1, bytes));
let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens");
assert!(!r.document().images.contains_key(&key));
}
/// A server's log written before these checks may hold an image they refuse: a restart replays
/// the edits after it instead of dropping them with the rest of the log.
#[test]
fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() {
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
let (key, bytes) = forged();
let good = b"an honest image after it".as_slice();
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])]
{
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), 2, "both batches count");
assert!(!r.document().images.contains_key(&key), "the refused image is left out");
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
}
/// A server's log written before these checks may give an image other bytes under its key (the
/// apply step allowed it then): a restart replays past that batch, keeping the first bytes and
/// every edit after it, instead of stopping there and dropping the rest of the log.
#[test]
fn a_restart_replays_past_a_logged_image_replacement() {
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
let good = b"an honest image after it".as_slice();
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
let journal = [
crate::Journal::Issued { client: 1 },
accepted(1, vec![put("img1", 2, 1, b"the first bytes")]),
accepted(2, vec![put("img1", 2, 1, b"other bytes")]),
accepted(3, vec![put(&sha256_hex(good), 2, 1, good)]),
];
for j in journal {
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), 3, "every batch counts");
assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out");
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
}
/// A document whose rectangle shows `img1` (the first bytes), and that rectangle.
fn in_use() -> (Document, figcraft_doc::NodeId) {
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
let id = doc.create(page, figcraft_doc::NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
doc.get_mut(id).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
(doc, id)
}
/// An image a layer or style still shows can't be removed: removing it and putting other bytes
/// under its key would swap the picture that layer shows, which giving the key other bytes
/// directly can't do (#1086).
#[test]
fn an_image_in_use_cannot_be_removed() {
let (mut doc, _) = in_use();
let remove = Change::Remove { entry: Entry::Image("img1".into()) };
assert!(apply_doc(&mut doc, &remove).is_err(), "removed while a layer shows it");
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err());
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
}
/// A batch that first stops using an image, then removes it (the order a diff makes), applies.
#[test]
fn an_image_no_longer_in_use_can_be_removed() {
let (doc, id) = in_use();
let mut r = crate::Replica::new(doc);
let batch = [Change::Delete { id }, Change::Remove { entry: Entry::Image("img1".into()) }];
r.apply(&batch, crate::Mode::Strict).expect("the layer goes first, then its image");
assert!(r.document().images.is_empty());
}
/// Only image entries are left out of saved history: a snapshot whose other changes don't apply
/// is still an error, not a document missing them.
#[test]
fn a_snapshot_with_a_broken_layer_change_still_fails_to_open() {
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
// A property of a layer that isn't there: an error when applied (unlike deleting it).
let missing = figcraft_doc::NodeId(u64::MAX);
snapshot.push(Change::Set { id: missing, prop: crate::change::Prop::Name, value: Json(json!("x")) });
assert!(crate::Replica::from_snapshot(&snapshot).is_err());
}
+149
View File
@@ -174,3 +174,152 @@ fn a_journal_with_a_gap_is_refused() {
assert_eq!(errors.len(), 1, "the batch after the gap");
assert_eq!(r.seq(), 0);
}
/// Restores `state` with one more batch from another client in its log (and in its snapshot,
/// `in_snapshot`, as a server that accepted it saved it), then has `a` resume into it.
fn resume_past(s: &Server, a: &mut Client, batch: Vec<crate::Change>, in_snapshot: bool) -> Server {
let mut state = s.state().unwrap();
if in_snapshot {
state.snapshot.retain(|c| {
!batch.iter().any(|b| matches!((b, c), (crate::Change::Put { entry: x, .. }, crate::Change::Put { entry: y, .. }) if x == y))
});
state.snapshot.extend(batch.iter().cloned());
}
state.seq += 1;
state.log.push(message::Accepted { seq: state.seq, author: a.id() + 1, batch_id: 1, changes: batch });
let mut r = Server::restore(state, "", Limits::default()).unwrap();
a.disconnect();
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
let (_, out) = r.join(h).unwrap();
deliver(a, &out);
r
}
/// A saved log may hold an image a newer server refuses: a client resuming from before it still
/// catches up, without that image (#1086).
#[test]
fn a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let (key, bytes) = crate::image_tests::forged();
let r = resume_past(&s, &mut a, vec![crate::image_tests::put(&key, 2, 1, bytes)], false);
assert!(!a.view().document().images.contains_key(&key));
assert_eq!(a.view(), r.replica());
}
/// A saved log may give an image other bytes under a key the resuming client already holds (an
/// older server allowed it): the client still catches up, to what the server holds (#1086).
#[test]
fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let m = edit(&mut a, |d| {
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
});
send(&mut s, a.id(), m, &mut [&mut a]);
let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true);
assert_eq!(a.view(), r.replica());
}
/// `a` adds a rectangle showing image `img1`; returns the rectangle.
fn add_image_layer(s: &mut Server, a: &mut Client) -> figcraft_doc::NodeId {
let page = s.document().first_page().unwrap();
let mut id = None;
let m = edit(a, |d| {
let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
id = Some(r);
});
send(s, a.id(), m, &mut [a]);
id.unwrap()
}
/// Undoing an image's insertion still works with images in use protected from removal (#1086).
#[test]
fn undoing_an_image_layer_still_works() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let id = add_image_layer(&mut s, &mut a);
for m in a.undo(&NoRecompute).unwrap() {
send(&mut s, a.id(), m, &mut [&mut a]);
}
assert!(s.document().node(id).is_none(), "the layer is gone");
assert_eq!(a.view(), s.replica());
}
/// A server's log written before the image checks may remove an image a layer still shows: a
/// restart replays past it, keeping the image and every edit after it (#1086).
#[test]
fn a_restart_replays_past_a_logged_removal_of_an_image_in_use() {
let mut s = Server::new(base(), "", Limits::default());
s.journaling(true);
let mut a = join_as(&mut s, Access::Edit);
add_image_layer(&mut s, &mut a);
let saved = s.state().unwrap();
let page = s.document().first_page().unwrap();
let mut journal = s.take_journal();
let base_seq = saved.seq;
let next = |n: u64| base_seq + n;
journal.push(Journal::Accepted(message::Accepted {
seq: next(1),
author: a.id(),
batch_id: 90,
changes: vec![crate::Change::Remove { entry: crate::change::Entry::Image("img1".into()) }],
}));
let rename = crate::Change::Set { id: page, prop: crate::change::Prop::Name, value: crate::change::Json(serde_json::json!("Renamed")) };
journal.push(Journal::Accepted(message::Accepted { seq: next(2), author: a.id(), batch_id: 91, changes: vec![rename] }));
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
for j in journal {
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), next(2));
assert!(r.document().images.contains_key("img1"), "the image a layer shows stays");
assert_eq!(r.document().node(page).unwrap().name, "Renamed", "the edit after it is kept");
}
/// What a resuming client is told to catch up with.
fn catch_up_of(r: &mut Server, a: &mut Client) -> message::CatchUp {
a.disconnect();
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
let (id, out) = r.join(h).unwrap();
out.into_iter()
.find_map(|o| match o {
Outgoing::To(t, ServerMsg::Welcome { catch_up, .. }) if t == id => Some(catch_up),
_ => None,
})
.expect("a welcome")
}
/// A restart that left an image out of the saved history (an older server accepted it) catches
/// every resuming client up with the snapshot, even one at the same sequence number: a client
/// that kept the image from before would otherwise go on showing what the session no longer
/// has (#1086).
#[test]
fn a_resume_after_history_was_left_out_catches_up_with_the_snapshot() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let mut state = s.state().unwrap();
let (key, bytes) = crate::image_tests::forged();
state.snapshot.push(crate::image_tests::put(&key, 2, 1, bytes));
let mut r = Server::restore(state, "", Limits::default()).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on restore");
// Left out on replay.
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let state = s.state().unwrap();
let mut r = Server::restore(state.clone(), "", Limits::default()).unwrap();
let batch = vec![crate::image_tests::put(&key, 2, 1, bytes)];
r.replay(Journal::Accepted(message::Accepted { seq: state.seq + 1, author: a.id(), batch_id: 90, changes: batch })).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on replay");
}
/// A restart that left nothing out keeps resuming clients on the cheaper delta.
#[test]
fn a_resume_after_a_clean_restart_catches_up_with_the_log() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let mut r = Server::restore(s.state().unwrap(), "", Limits::default()).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Delta(_)));
}
+5 -2
View File
@@ -88,7 +88,8 @@ pub struct Limits {
pub max_log_batches: usize,
/// Most changes in those batches together.
pub max_log_changes: usize,
/// About how many bytes those batches may hold together ([`server::weight`]).
/// About how many bytes those batches may hold together ([`server::weight`]). 64 MiB by
/// default, so a long busy session (on a desktop host too) keeps a bounded log.
pub max_log_bytes: usize,
}
@@ -102,7 +103,7 @@ impl Default for Limits {
max_name: 64,
max_log_batches: 10_000,
max_log_changes: 1_000_000,
max_log_bytes: usize::MAX,
max_log_bytes: 64 << 20,
}
}
}
@@ -136,6 +137,8 @@ mod entry_ids_tests;
#[cfg(test)]
mod frac_tests;
#[cfg(test)]
mod image_tests;
#[cfg(test)]
mod journal_tests;
#[cfg(test)]
mod merge_list_tests;
+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
+37
View File
@@ -133,6 +133,43 @@ fn a_long_absence_catches_up_with_a_snapshot() {
assert_eq!(a.view(), s.replica());
}
#[test]
fn the_default_log_is_bounded_in_bytes_and_a_client_behind_it_catches_up_with_a_snapshot() {
// The defaults are what a desktop host runs with.
let limits = Limits::default();
assert_eq!(limits.max_log_bytes, 64 << 20);
let mut s = Server::new(base(), "tok", limits);
let mut a = join(&mut s);
let mut b = join(&mut s);
let page = s.document().first_page().unwrap();
a.disconnect();
s.leave(a.id());
// Nine renames of 8 MiB each: more than the log holds, in a few batches.
for i in 0..9 {
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = format!("{i}{}", "y".repeat(8 << 20))).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
assert!(s.log_bytes() <= limits.max_log_bytes, "{} bytes kept", s.log_bytes());
}
assert!(s.log_bytes() > 0, "recent batches are still kept");
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(_), .. })));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
// Within the log, a short absence still gets the missed batch only.
a.disconnect();
s.leave(a.id());
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Later".into()).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Delta(d), .. }) if d.len() == 1));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
}
#[test]
fn after_a_snapshot_undo_leaves_what_others_changed_meanwhile() {
let mut s = Server::new(base(), "tok", Limits { max_log_batches: 2, ..Limits::default() });
+45 -4
View File
@@ -72,12 +72,47 @@ impl Replica {
/// Rebuild a replica from a [`snapshot`](Self::snapshot).
pub fn from_snapshot(changes: &[Change]) -> Result<Replica, Error> {
Self::from_snapshot_leaving_out(changes).map(|(r, _)| r)
}
/// [`from_snapshot`](Self::from_snapshot), and how many image entries it left out: a
/// snapshot saved before the image checks may hold one they refuse, and opens without it.
pub(crate) fn from_snapshot_leaving_out(changes: &[Change]) -> Result<(Replica, usize), Error> {
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
r.apply(changes, Mode::Strict)?;
let mut changes = changes.to_vec();
let left_out = r.apply_kept(&mut changes, Change::is_image)?;
if r.doc.pages.is_empty() {
return Err(Error::Invalid("a snapshot needs a page".into()));
}
Ok(r)
Ok((r, left_out))
}
/// Apply `changes` strictly. When they don't apply together — history written under older
/// rules, or changes that apply one by one but fail the batch's checks together — apply those
/// that do, one by one in order, and leave the rest out of `changes`, as long as `may_leave_out`
/// allows it (otherwise that change's error). Returns how many were left out.
pub(crate) fn apply_kept(&mut self, changes: &mut Vec<Change>, may_leave_out: impl Fn(&Change) -> bool) -> Result<usize, Error> {
let mut all = self.clone();
if all.apply(changes, Mode::Strict).is_ok() {
*self = all;
return Ok(0);
}
let given = changes.len();
let mut kept = Vec::with_capacity(given);
for c in changes.drain(..) {
let mut t = self.clone();
match t.apply(std::slice::from_ref(&c), Mode::Strict) {
Ok(_) => {
*self = t;
kept.push(c);
}
Err(e) if !may_leave_out(&c) => return Err(e),
Err(_) => {}
}
}
let left_out = given - kept.len();
*changes = kept;
Ok(left_out)
}
/// The whole state as changes that build it from nothing (pages and their trees in order,
@@ -140,9 +175,15 @@ impl Replica {
}
/// Where `id` with `key` goes among `siblings` (sorted by `(key, id)`; `id` itself skipped):
/// how many of them sort before it.
/// how many of them sort before it. A binary search on each side of `id`, which may still
/// sit where its old key put it.
fn slot(&self, siblings: &[NodeId], id: NodeId, key: &str) -> usize {
siblings.iter().filter(|c| **c != id && (self.key(**c), **c) < (key, id)).count()
let before = |c: &NodeId| (self.key(*c), *c) < (key, id);
let (head, tail) = match siblings.iter().position(|c| *c == id) {
Some(i) => (siblings.get(..i).unwrap_or_default(), siblings.get(i + 1..).unwrap_or_default()),
None => (siblings, &[][..]),
};
head.partition_point(before) + tail.partition_point(before)
}
/// Apply `changes` in order. Node changes come first in a batch made by [`diff`](crate::diff);
+57 -21
View File
@@ -25,6 +25,12 @@ pub enum Outgoing {
/// Why a viewer's batch is refused.
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
/// A new style, variable, collection or comment may not take an id in a space at or above this
/// that isn't its creator's. Client ids start above every space in the document after a restart,
/// so no member can use them up: at least this many always remain. Lower spaces stay open, for
/// entries imported from other files' sessions.
pub const NO_SESSION_SPACE: u32 = 1 << 31;
/// What changed a server's lasting state, in order: what a store appends to its log between
/// snapshots ([`Server::journaling`]) and replays over the last snapshot after a restart
/// ([`Server::replay`]). Presence and who is connected aren't in it.
@@ -77,6 +83,10 @@ pub struct Server {
signals: BTreeSet<u32>,
/// Who spotlights now (a newcomer is told).
spotlight: Option<u32>,
/// Saved history this server restored or replayed left image entries out (or may give a
/// resuming client other bytes under a key it holds): every resuming client catches up with
/// the snapshot, not the log.
resync: bool,
}
/// The most client ids a session remembers for reconnecting (the oldest disconnected ones go
@@ -127,6 +137,7 @@ impl Server {
journal: None,
signals: BTreeSet::new(),
spotlight: None,
resync: false,
}
}
@@ -148,16 +159,28 @@ impl Server {
/// reconnect with their ids). A log that doesn't lead up to the sequence number is dropped
/// (clients then catch up with a snapshot).
pub fn restore(state: SessionState, token: impl Into<String>, limits: Limits) -> Result<Server, Error> {
let replica = Replica::from_snapshot(&state.snapshot)?;
let (replica, left_out) = Replica::from_snapshot_leaving_out(&state.snapshot)?;
let mut server = Server::new(replica.document().clone(), token, limits);
server.state = replica;
server.session = state.session;
server.seq = state.seq;
server.next_client = server.next_client.max(state.next_client);
// A counter stuck at the top (saved while an out-of-range id in the document blocked
// every join) carries on above the ids actually handed out.
let saved = match state.next_client {
u32::MAX => state.issued.keys().chain(state.log.iter().map(|a| &a.author)).max().map_or(0, |c| c.saturating_add(1)),
n => n,
};
server.next_client = server.next_client.max(saved);
server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect();
server.deleted = state.deleted;
server.removed = state.removed;
let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq));
// History saved before the image checks may hold image entries they refuse. The snapshot
// opens without them, and a saved log putting images may give a resuming client other
// bytes under a key it holds, which this state can't check: either way resuming clients
// catch up with the snapshot.
let puts_images = state.log.iter().flat_map(|a| &a.changes).any(Change::is_image);
server.resync = left_out > 0 || puts_images;
if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) {
for a in state.log {
server.remember(a);
@@ -198,10 +221,16 @@ impl Server {
Ok(())
}
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
Journal::Accepted(acc) => {
Journal::Accepted(mut acc) => {
if acc.seq != self.seq + 1 {
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
}
// A log written before the image checks may hold an image entry they refuse: the
// batch replays without it, so the edits after it aren't dropped with the rest of
// the log, and resuming clients catch up with the snapshot.
if acc.changes.iter().any(Change::is_image) && self.state.clone().apply_kept(&mut acc.changes, Change::is_image)? > 0 {
self.resync = true;
}
self.accept(acc.author, self.seq, &acc.changes)?;
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
self.next_client = self.next_client.max(acc.author.saturating_add(1));
@@ -284,7 +313,9 @@ impl Server {
// The log holds every batch after `floor`.
let floor = self.log.front().map_or(self.seq, |a| a.seq.saturating_sub(1));
let catch_up = match resume {
Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
Some(r) if !self.resync && r.seq <= self.seq && r.seq >= floor => {
CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect())
}
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
};
let (id, secret) = match resume {
@@ -481,9 +512,9 @@ impl Server {
/// The document-level entries (styles, variables, collections, comments) a batch adds or
/// removes, each with whether it is there afterwards. A new one is refused when its id lies in
/// the space of another client this session gave an id to (unless it was removed before: undo
/// puts entries back); ids from before the session (space 0, earlier sessions, other files)
/// are fine.
/// the space of another client this session gave an id to, or in a space at or above
/// [`NO_SESSION_SPACE`] (unless it was removed before: undo puts entries back); ids from
/// before the session (space 0, earlier sessions, other files) are fine.
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
let before = self.state.document();
let mut touched: BTreeSet<Entry> = BTreeSet::new();
@@ -510,7 +541,7 @@ impl Server {
}
if now
&& !self.removed.contains(&entry)
&& let Some(space) = entry.id_space().filter(|s| *s != from && self.issued.contains_key(s))
&& let Some(space) = entry.id_space().filter(|s| *s != from && (self.issued.contains_key(s) || *s >= NO_SESSION_SPACE))
{
return Err(Error::Invalid(format!("new {} isn't in client {from}'s id space (it is client {space}'s)", entry.describe())));
}
@@ -526,6 +557,7 @@ impl Server {
let doc = self.state.document();
let mut props: BTreeMap<NodeId, BTreeSet<Prop>> = BTreeMap::new();
let mut gone: BTreeSet<NodeId> = BTreeSet::new();
let (mut asked, mut sent) = (BTreeSet::new(), BTreeSet::new());
let mut out = Vec::new();
for c in changes {
match c {
@@ -538,9 +570,11 @@ impl Server {
Change::Delete { id } => {
gone.insert(*id);
}
// The same target has the same fix: look each one up once, and send each fix once.
other => {
if let Some(fix) = docprops::current(doc, other)?
&& !out.contains(&fix)
if Target::of(other).is_some_and(|t| asked.insert(t))
&& let Some(fix) = docprops::current(doc, other)?
&& Target::of(&fix).is_some_and(|t| sent.insert(t))
{
out.push(fix);
}
@@ -558,19 +592,21 @@ impl Server {
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
}
}
let mut restored = std::collections::BTreeSet::new();
// Each deleted node's subtree, pre-order, every node once: a restored node's whole subtree
// went out with it, so the walk stops there (deleting a deep chain stays linear).
let mut restored = BTreeSet::new();
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if restored.insert(d)
&& let Some(n) = doc.node(d)
{
nodes.push(create_change(n, self.state.key(d))?);
}
}
if !doc.contains(id) {
nodes.push(Change::Delete { id });
continue;
}
let mut stack = vec![id];
while let Some(d) = stack.pop() {
let Some(n) = doc.node(d) else { continue };
if restored.insert(d) {
nodes.push(create_change(n, self.state.key(d))?);
stack.extend(n.children.iter().rev());
}
None => nodes.push(Change::Delete { id }),
}
}
nodes.extend(out);
+38 -1
View File
@@ -2,7 +2,7 @@
use figcraft_doc::{Document, Node, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::change::{Change, Entry, Json, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Presence, ServerMsg};
use crate::replica::Mode;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
@@ -217,6 +217,43 @@ fn fixes_roll_a_rejected_client_back_to_the_server_state() {
assert_eq!(b.view(), s.replica());
}
#[test]
fn a_refused_deep_delete_restores_each_node_once_in_linear_time() {
const DEPTH: usize = 20_000;
let (mut d, _, frame) = doc();
let mut chain = vec![frame];
while chain.len() < DEPTH {
let parent = *chain.last().unwrap();
chain.push(d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 1.0, 1.0).unwrap());
}
let mut s = Server::new(d, "tok", Limits::default());
let a = join(&mut s);
// Every node of the chain deleted, plus a rename of a node nobody has: refused whole.
let missing = NodeId(u64::MAX >> 1);
let mut changes: Vec<Change> = chain.iter().map(|&id| Change::Delete { id }).collect();
changes.push(Change::Set { id: missing, prop: Prop::Name, value: Json("x".into()) });
// As many comment threads put, each twice: they go back to not existing, once each.
let put = |c: u64| Change::Put { entry: Entry::Comment(c), value: Json(serde_json::json!({})) };
changes.extend((1..=DEPTH as u64).chain(1..=DEPTH as u64).map(put));
let started = std::time::Instant::now();
let (_, fixes) = rejected(&mut s, a.id(), changes);
let took = started.elapsed();
assert_eq!(fixes.first(), Some(&Change::Delete { id: missing }));
let restored: Vec<NodeId> = fixes[1..=DEPTH]
.iter()
.map(|c| match c {
Change::Create { id, .. } => *id,
other => panic!("not a creation: {other:?}"),
})
.collect();
assert_eq!(restored, chain, "each deleted node comes back once, parents first");
let removed: Vec<Change> = (1..=DEPTH as u64).map(|c| Change::Remove { entry: Entry::Comment(c) }).collect();
assert_eq!(fixes[DEPTH + 1..], removed[..], "each entry once, in batch order");
// Walking every deleted node's subtree again and comparing each fix with all the earlier
// ones took 27 s in a debug build; once each takes 0.1 s.
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
#[test]
fn remote_batches_apply_under_pending_ones() {
let (d, _, frame) = doc();
+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}");
}
}
+80 -4
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
@@ -122,7 +124,8 @@ impl Color {
/// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits
/// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
@@ -146,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('#') {
@@ -185,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);
}
@@ -194,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).
@@ -225,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() }
}
@@ -426,9 +486,23 @@ impl ImagePaint {
{
return c;
}
self.fitted_crop(iw, ih, w, h, false)
}
/// The crop transform that keeps the picture where its scale mode draws it: the crop in effect
/// in Crop mode, the Fit placement in Fit mode, else (Fill, Tile) the Fill placement.
pub fn placement_crop(&self, iw: f64, ih: f64, w: f64, h: f64) -> [f64; 6] {
match self.scale_mode {
ImageScaleMode::Crop => self.effective_crop(iw, ih, w, h),
mode => self.fitted_crop(iw, ih, w, h, mode == ImageScaleMode::Fit),
}
}
/// The Fill placement (`fit`: the Fit placement) as a crop transform.
fn fitted_crop(&self, iw: f64, ih: f64, w: f64, h: f64, fit: bool) -> [f64; 6] {
let (rw, rh) = self.rotated_size(iw.max(1.0), ih.max(1.0));
let (w, h) = (w.max(1e-6), h.max(1e-6));
let s = (w / rw).max(h / rh);
let s = if fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
let (x0, y0) = ((w - rw * s) / 2.0, (h - rh * s) / 2.0);
[w / (rw * s), 0.0, 0.0, h / (rh * s), -x0 / (rw * s), -y0 / (rh * s)]
}
@@ -663,6 +737,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
}
+17
View File
@@ -115,6 +115,15 @@ fn infinite_channels_clamp() {
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
#[test]
fn hex_allows_at_most_one_leading_hash() {
assert!(Color::from_hex("##ff0000").is_none());
assert!(Color::from_hex("###fff").is_none());
assert!(Color::from_hex_or_name("##ff0000").is_none());
assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000");
assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000");
}
#[test]
fn malformed_alpha_or_extra_args_rejected() {
assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None);
@@ -124,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.
+56 -1
View File
@@ -2,7 +2,7 @@
//! picks the group, and a double click goes one level deeper than the selection.
use crate::geometry::{enter_target, select_target};
use crate::{BoolOp, Document, NodeId, NodeKind};
use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::Point;
fn page(d: &Document) -> NodeId {
@@ -40,6 +40,61 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
assert_eq!(listed, vec![f, g, b]);
}
#[test]
fn ordinary_hit_uses_region_paints_without_layer_fill() {
let mut d = Document::new("region hit");
let p = page(&d);
let network = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)],
segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(),
regions: vec![
Region {
loops: vec![vec![0, 1, 2, 3]],
winding: WindingRule::NonZero,
fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]),
..Default::default()
},
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
],
};
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(id).unwrap().fills.clear();
assert!(d.node(id).unwrap().fills.is_empty());
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id));
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
}
#[test]
fn inherited_paint_frames_use_per_region_hit_testing() {
let mut d = Document::new("inherited region hit");
let p = page(&d);
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
network.regions[0].winding = WindingRule::EvenOdd;
network.regions.push(network.regions[0].clone());
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel");
let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") };
network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY });
assert!(network.regions.iter().all(|region| region.fills.is_none()));
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently");
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
}
#[test]
fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() {
let mut d = Document::new("stroke selection");
let p = page(&d);
let mut network =
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
network.regions[0].fills = Some(Vec::new());
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
let n = d.get_mut(id).unwrap();
n.fills.clear();
n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
n.stroke.weight = 1.0;
assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
}
#[test]
fn double_clicks_go_one_level_deeper() {
let (d, f, g, b, r1, _) = setup();
+3 -1
View File
@@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() {
assert!(d.validate().is_empty());
}
/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer
/// that isn't a page, and repairs into a file with a page.
#[test]
fn pages_can_be_inserted_onto_a_page_less_base() {
let mut d = Document::without_pages("snapshot");
assert!(d.pages.is_empty() && d.node_count() == 0);
let p = Node::new(
NodeId(C3 | 1),
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() },
"P",
0.0,
0.0,
+3
View File
@@ -60,6 +60,9 @@ impl Node {
f(&mut prototype.background);
}
NodeKind::Text(t) => t.for_each_color_mut(f),
NodeKind::Vector { network } => {
network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f))
}
_ => {}
}
for it in &mut self.interactions {
+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,
Loaded 100 of 842 files, more files were not shown because too many files have changed in this diff. Show more