Compare commits

...
Author SHA1 Message Date
bflatastic 9f935718fe Merge branch 'rb-578' into batch/t6
# Conflicts:
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:37:43 +07:00
bflatastic 1c863aa9f5 Merge branch 'rb-568' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 891f0acb3c Merge branch 'rb-560' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 55c05338a1 Merge branch 'rb-554' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 4e3fd82bd1 Merge branch 'rb-549' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 41b08a2393 Merge branch 'rb-528' into batch/t6 2026-10-16 02:36:54 +07:00
bflatastic 730e377418 Merge branch 'rb-487' into batch/t6 2026-10-16 02:36:53 +07:00
bflatastic 175fdfa428 Merge remote-tracking branch 'origin/main' into rb-478
# Conflicts:
#	crates/ui-egui/src/idle_tests.rs
#	crates/ui-egui/src/lib.rs
2026-10-16 02:36:34 +07:00
bflatastic d60a27b675 Merge main into #554: one id map for the copy, and #562's flows, prototype and guides follow it
page.duplicate copies every layer with duplicate_all_into (navigation between
copied frames points at the copies) and builds the old-to-new list for flows
from that copy, instead of copying each layer separately.
2026-10-16 02:34:54 +07:00
bflatastic fd6a7e0ba3 Merge main into #549: keep #539's whole URL and add the empty-URL arm 2026-10-16 02:33:05 +07:00
bflatastic db8bf6ba33 Merge main into #560; normalise the replacement where findReplace reads it
Main's case-insensitive replace loop (from an earlier PR) took the place of the
loop this change edited, so the replacement text is normalised once, where
the parameter is read.
2026-10-16 02:31:34 +07:00
bflatastic 0a84dee777 Merge remote-tracking branch 'origin/main' into rb-578
# Conflicts:
#	crates/text/src/fontdb.rs
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:27:50 +07:00
bflatastic 46316c9895 Merge remote-tracking branch 'origin/main' into rb-568
# Conflicts:
#	ROADMAP.md
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:26:29 +07:00
bflatastic 0e1ccb55d2 Merge remote-tracking branch 'origin/main' into rb-487
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-16 02:25:11 +07:00
bflatastic ec8a506353 Merge remote-tracking branch 'origin/main' into rb-528 2026-10-16 02:23:46 +07:00
BFlat eb741a362d Community batch 5: #312's sturdier zoom-preview test; record four rebased heads (#588)
Lands #312's test-only follow-up commit and records #326, #317 and #306's rebased heads (no content change) so they show as merged.
2026-10-16 02:23:45 +07:00
BFlat 97e422226f Merge pull request #591 from storytold/community/batch-5
Community batch 5: 59 reviewed PRs (#482 and up)
2026-10-16 02:15:47 +07:00
BFlat 8ba09001cd M0.9: a control line that isn't JSON ends the connection (#589)
The control server answered a line that wasn't JSON with an error and read
on. A web page can make the browser send an HTTP request to the loopback
port; its request and header lines were skipped that way and the JSON lines
of its body ran as commands. The first line that isn't JSON now closes the
connection before anything after it is read. Clients that send JSON lines
(MCP --attach, scripts) are unaffected.
2026-10-16 02:15:18 +07:00
bflatastic ecad646330 Merge branch 'pr-584' into batch/t5 2026-10-16 01:36:24 +07:00
bflatastic 87cb799783 Merge branch 'pr-583' into batch/t5
# Conflicts:
#	docs/ui-parity.md
2026-10-16 01:36:24 +07:00
bflatastic 466fd249fe Merge branch 'pr-579' into batch/t5
# Conflicts:
#	docs/ui-parity.md
2026-10-16 01:36:18 +07:00
bflatastic a4ba67ac22 Merge branch 'pr-577' into batch/t5 2026-10-16 01:36:13 +07:00
bflatastic 99b6495b7d Merge branch 'pr-576' into batch/t5 2026-10-16 01:36:12 +07:00
bflatastic 6eb9f38487 Merge branch 'pr-566' into batch/t5 2026-10-16 01:36:12 +07:00
bflatastic bf4d9e0f6f Merge branch 'pr-565' into batch/t5 2026-10-16 01:36:11 +07:00
bflatastic 9418e2c8bb Merge branch 'pr-564' into batch/t5 2026-10-16 01:36:10 +07:00
bflatastic c4c88900c4 Merge branch 'pr-563' into batch/t5 2026-10-16 01:36:10 +07:00
bflatastic aadaa2d975 Merge branch 'pr-562' into batch/t5 2026-10-16 01:36:09 +07:00
bflatastic 580eee3572 Merge branch 'pr-561' into batch/t5 2026-10-16 01:36:08 +07:00
bflatastic e359d856f2 Merge branch 'pr-559' into batch/t5 2026-10-16 01:36:07 +07:00
bflatastic e08c1c598f Merge branch 'pr-558' into batch/t5 2026-10-16 01:36:06 +07:00
bflatastic 4df3702dbb Merge branch 'pr-556' into batch/t5 2026-10-16 01:36:05 +07:00
bflatastic b7c692171d Merge branch 'pr-551' into batch/t5 2026-10-16 01:36:03 +07:00
bflatastic d95aef1d0c Merge branch 'pr-547' into batch/t5 2026-10-16 01:36:02 +07:00
bflatastic 887e374c58 Merge branch 'pr-545' into batch/t5 2026-10-16 01:36:00 +07:00
bflatastic 34dcf852d5 Merge branch 'pr-542' into batch/t5 2026-10-16 01:35:59 +07:00
bflatastic 5e7eb51e46 Merge branch 'pr-539' into batch/t5 2026-10-16 01:35:58 +07:00
bflatastic d40fd80be7 Merge branch 'pr-536' into batch/t5 2026-10-16 01:35:58 +07:00
bflatastic 1c5b4d8450 Merge branch 'pr-534' into batch/t5 2026-10-16 01:35:57 +07:00
bflatastic ef69a7f8e9 Merge branch 'pr-533' into batch/t5 2026-10-16 01:35:57 +07:00
bflatastic a1849aae15 Merge branch 'pr-531' into batch/t5 2026-10-16 01:35:56 +07:00
bflatastic 1e25e239c2 Merge branch 'pr-527' into batch/t5 2026-10-16 01:35:55 +07:00
bflatastic 7bda68876b Merge branch 'pr-526' into batch/t5 2026-10-16 01:35:55 +07:00
bflatastic 362bdce73b Merge branch 'pr-525' into batch/t5 2026-10-16 01:35:54 +07:00
bflatastic 5e99e71728 Merge branch 'pr-523' into batch/t5 2026-10-16 01:35:53 +07:00
bflatastic 09e85240ea Merge branch 'pr-522' into batch/t5 2026-10-16 01:35:52 +07:00
bflatastic 7998845515 Merge branch 'pr-521' into batch/t5 2026-10-16 01:35:51 +07:00
bflatastic e8eca728fe Merge branch 'pr-519' into batch/t5 2026-10-16 01:35:50 +07:00
bflatastic 438d468c76 Merge branch 'pr-518' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:50 +07:00
bflatastic 07130947f9 Merge branch 'pr-517' into batch/t5 2026-10-16 01:35:45 +07:00
bflatastic 16e26b69ff Merge branch 'pr-516' into batch/t5 2026-10-16 01:35:44 +07:00
bflatastic cd5c70a244 Merge branch 'pr-515' into batch/t5 2026-10-16 01:35:44 +07:00
bflatastic e6240f6229 Merge branch 'pr-514' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:43 +07:00
bflatastic 1498c0ea52 Merge branch 'pr-513' into batch/t5 2026-10-16 01:35:39 +07:00
bflatastic 67cf5ecfd5 Merge branch 'pr-512' into batch/t5 2026-10-16 01:35:39 +07:00
bflatastic 0c3c00db99 Merge branch 'pr-511' into batch/t5 2026-10-16 01:35:38 +07:00
bflatastic 7988e48b5d Merge branch 'pr-509' into batch/t5 2026-10-16 01:35:37 +07:00
bflatastic 0105724108 Merge branch 'pr-508' into batch/t5 2026-10-16 01:35:36 +07:00
bflatastic 2894195ab3 Merge branch 'pr-506' into batch/t5 2026-10-16 01:35:35 +07:00
bflatastic 02156c0edc Merge branch 'pr-505' into batch/t5 2026-10-16 01:35:35 +07:00
bflatastic 06844dc703 Merge branch 'pr-504' into batch/t5 2026-10-16 01:35:34 +07:00
bflatastic 2d1a7c973d Merge branch 'pr-503' into batch/t5 2026-10-16 01:35:33 +07:00
bflatastic 77c9ccd649 Merge branch 'pr-502' into batch/t5 2026-10-16 01:35:32 +07:00
bflatastic 95b32053d5 Merge branch 'pr-499' into batch/t5 2026-10-16 01:35:31 +07:00
bflatastic b1760ff098 Merge branch 'pr-497' into batch/t5 2026-10-16 01:35:30 +07:00
bflatastic b0aa597787 Merge branch 'pr-496' into batch/t5 2026-10-16 01:35:29 +07:00
bflatastic dcb36e376f Merge branch 'pr-495' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:28 +07:00
bflatastic 139195cb0c Merge branch 'pr-494' into batch/t5 2026-10-16 01:35:24 +07:00
bflatastic 991cb604c2 Merge branch 'pr-493' into batch/t5 2026-10-16 01:35:23 +07:00
bflatastic 1ef42e6504 Merge branch 'pr-491' into batch/t5 2026-10-16 01:35:23 +07:00
bflatastic d7de56f046 Merge branch 'pr-490' into batch/t5 2026-10-16 01:35:22 +07:00
bflatastic 4bc63d506c Merge branch 'pr-489' into batch/t5 2026-10-16 01:35:21 +07:00
bflatastic 567110f063 Merge branch 'pr-488' into batch/t5 2026-10-16 01:35:20 +07:00
bflatastic ffde863e92 Merge branch 'pr-486' into batch/t5 2026-10-16 01:35:19 +07:00
bflatastic e2b567d5b3 Merge branch 'pr-485' into batch/t5 2026-10-16 01:35:16 +07:00
bflatastic 77b6f1fc12 Merge branch 'pr-483' into batch/t5 2026-10-16 01:35:14 +07:00
bflatastic 97b10b637d Merge branch 'pr-482' into batch/t5
# Conflicts:
#	docs/localization-parity.md
#	docs/mcp.md
#	docs/translating.md
2026-10-16 01:35:12 +07:00
bflatastic 7da4cf5e31 Export frames to PDF: use the asynchronous save dialog
Main's file dialogs answer asynchronously now (they no longer re-enter the
event loop on macOS); the menu item takes the path in a callback, and the test
draws a frame to let the dialog answer.
2026-10-16 01:34:36 +07:00
bflatastic a8416b27c1 Merge remote-tracking branch 'origin/main' into fix-502 2026-10-16 01:30:26 +07:00
bflatastic 9552e530be Swedish: command labels that only repeated the command id show in English
468 of the 588 command and menu rows had the command id as their text
("align.bottom" = "align.bottom"), which the app would have shown as is.
They are left out, so English shows until they are translated. Swedish
isn't in the complete list (that would make every new UI string need a
Swedish translation in the same change); the docs say what it covers.
2026-10-16 01:30:10 +07:00
bflatastic fe5a22e468 Colour picker: record recent colours by the write's live flag
A text edit holds an interaction too, so treating any open interaction as a
drag dropped the colours clicked while editing text. Recent colours dedupe by
8-bit value: the colour picked and the one the document stores differ by a
rounding fraction.
2026-10-16 01:23:21 +07:00
bflatastic 59fe9b5ea8 Record #326, #317 and #306's rebased heads (same content as landed in #530 and #529) 2026-10-16 01:17:17 +07:00
bflatastic 862d73bf9f Merge #312: test(canvas): grow the slow zoom-preview scene until it crosses the threshold (@tylerjenningsw) 2026-10-16 01:17:16 +07:00
BFlat 9dcfe4ecbc Community batch 4: 11 reviewed PRs (#530)
Lands 11 reviewed community PRs with their own commits plus maintainer fixes on their branches: #305 #363 #400 #338 #272 #368 #352 #308 #322 #317 #306.
2026-10-16 01:11:44 +07:00
BFlat 2fe7a9449a Community batch 3: 20 reviewed PRs (#529)
Lands 20 reviewed community PRs with their own commits plus maintainer fixes on their branches: #257 #263 #312 #253 #303 #331 #336 #280 #313 #332 #279 #281 #300 #326 #361 #310 #474 #346 #469 #477. Also: the FreeBSD CI VM installs DejaVu, so its text tests measure with a real font (they failed on main's nightly too).
2026-10-16 01:11:29 +07:00
bflatastic 916ee3276c #306: say why the window can't open in the interface language
The message now comes from the UI crate (dialogs::window_failed) through
tr() and trf(), with entries in every catalog, and the app picks the
language from the preferences before the event loop starts, so the box
reads in it even though no frame was ever drawn. The commands to type stay
as written.
2026-10-15 20:24:05 +03:00
Barzabel 97f9c2fc5d M1: say why when the window can't open (#261)
Release builds on Windows have no console, so an eframe::run_native error
(no usable graphics adapter, a device the driver refuses) went to an unseen
standard error and the app quit silently. The error is now logged and shown
in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise
update the driver or try another graphics API (OpenGL on Windows), and
where to report it. With --control the box is skipped so agents aren't
blocked.
2026-10-15 20:24:04 +03:00
bflatastic 4a2d19f0db #326: decode at most the image crate's default 512 MiB per image; a full image cache drops its least recently used images, not all of them 2026-10-15 20:23:07 +03:00
bflatastic a7bcc6d6ff #317: compute the backdrop once under a casting layer
With drop shadows the backdrop was read twice: before the content and
again after the shadows. The first read is only what backdrop effects
inside the content see, so it now runs only when the layer has such
descendants, and the content is copied for the shadow caster only when it
gets a layer blur, noise or texture. The output is unchanged.
2026-10-15 20:23:07 +03:00
Barzabel f4d53a659b M1: memory budgets for decoded images and a file's images (#203, #189) 2026-10-15 20:23:07 +03:00
Barzabel 3074db1cbb M1: drop shadows under a background blur (#255)
A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.

When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
2026-10-15 20:23:07 +03:00
Marco Arnulfo 4f9023d80f Cmd/Ctrl+= and - zoom the canvas only, not the whole interface (fixes #580) 2026-10-15 19:04:53 +02:00
Mateus Villain 825bb708df M7: fields select their text on focus
A click or Tab into a one-line field selects all of its text, so typing
replaces the value, as in Figma: hex and colour fields, inline fields
(a fill's hex, variable values, renames), names, the font size field,
search boxes and dialog fields (widgets::select_all_on_focus). A drag
from the press still selects only what it covers; multi-line fields
keep the caret where clicked.

The first click into an inline field (a fill's hex) now takes the
keyboard: egui dropped the focus of the editor created by that click,
so it took a second click.
2026-10-15 13:54:04 -03:00
Ivan Shishkanov 55db465d8e M7: scrub typography spacing from leading icons (#373) 2026-10-15 19:28:46 +03:00
bkimandClaude Opus 5.5 e9e2d87405 M1: Hangul falls back to a Korean system font, at the run's weight
Text in the default Inter (or any Latin font) drew the missing-glyph box for every Hangul
syllable on macOS and Windows: no Korean family was on the fallback list, so the Korean
fonts those systems ship were never tried, and there is no default-font setting to work
around it. Apple SD Gothic Neo (macOS), Malgun Gothic (Windows) and Noto Sans CJK KR /
Noto Sans KR (Linux) now come after the Chinese and Japanese entries, so Han characters
resolve as before.

Fallback faces also follow the run's style: each fallback family gives its face nearest the
run's style instead of always its Regular, so Hangul (or kana) in a bold run comes out bold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015MAPjgJo17KasfbzMTbV65
2026-10-16 01:23:06 +09:00
RealtaNuaandClaude Opus 5.5 44128f7e97 fix(render): the rim light fades out at its reach instead of stopping (#256)
Where the 512 px cap held the light short of half a level, it dropped to 0
in one pixel: a ring 512 px inside a deep pane (46 levels at depth 300,
splay 1, 200%). The falloff now reaches 0 at the reach. Where the cap
doesn't bite this moves the light by at most half a level.

Cut tests now also cover rounded corners and a look under the cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 23:18:45 +07:00
Mateus Villain 782c9757f3 M3: CSS colour names in hex fields (red → FF0000)
Every hex field (fills, strokes, text ranges, effects, layout grids,
colour variables, page and prototype backgrounds, the colour picker)
now takes any of the 148 CSS named colours. Case, spaces, hyphens and
underscores are ignored; hex wins where both could match; names are
opaque and keep the colour's opacity. Color::parse_css (the picker's
CSS field and command colour params) takes the full list too.
2026-10-15 13:02:30 -03:00
IanHollow 5454bdc244 M7: skip special files when scanning system fonts
Require resolved font entries to be regular files before indexing them. Preserve normal font symlinks and cover named pipes in a bounded child-process regression.

Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-15 08:29:11 -07:00
MarekSpodBiedry 843b00b22f text: scripted text stores CRLF, CR, U+2028 and U+2029 as LF like the editor (#427) 2026-10-15 17:20:58 +02:00
MarekSpodBiedry 9fa99f8594 Keep per-character text fills in SVG export (#429) 2026-10-15 17:20:43 +02:00
MarekSpodBiedry 85d97a831c Keep flows, prototype settings and guides when duplicating a page (#417) 2026-10-15 17:19:38 +02:00
MarekSpodBiedry 7d0f9d7518 Colour picker: record only the final colour of a drag in Recent colors (#395) 2026-10-15 17:19:35 +02:00
RealtaNuaandClaude Opus 5.5 4588832c80 test(render): name the glass cut tests for what they check
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:21 +07:00
RealtaNuaandClaude Opus 5.5 bc5b474293 perf(render): sample once where glass doesn't bend
Where nothing bends (past `depth`, or with no refraction) the three
colour channels sample the backdrop at the same point: one sample
instead of three. The rim light's facing and powers are worked out only
where the light reaches. Same bytes: an ignored `glass_digests` test
prints digests of 30 renders (5 looks, square and rounded, 3 scales),
identical before and after this change (and to main).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:00 +07:00
RealtaNuaandClaude Opus 5.5 18e3070d4c fix(render): glass looks the same wherever a render cuts it
The glass light falls off with the distance to the edge, read past
`depth` (up to ~3 × depth at full splay), but the coverage window around
a cut layer was only `depth + 2` wide. With frost and refraction at 0
the backdrop margin is 0 too, so a band or canvas patch edge running
through the pane changed the light near it: up to 12 levels between a
banded and a whole render.

The window now covers the light's reach (where it falls under half a
level, clamped at 0 and capped at 512 px) plus the span the normals are
smoothed over, and past that reach the light is 0 instead of whatever
the window happened to hold.

Tests: a cut 50–80 px inside the pane equals the whole render byte for
byte (fails on main by 12 levels); light_reach edge cases. Adds an
ignored release timing test (glass_bench_tests).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:00 +07:00
MarekSpodBiedry 3062c6c4d6 Keep the selected guide selected when an earlier guide is removed (#418) 2026-10-15 17:18:38 +02:00
MarekSpodBiedry 4376f659ac Docs: FIG export guides no longer say variables and style links are omitted (#398) 2026-10-15 17:18:32 +02:00
MarekSpodBiedry 0ba8e30fbc docs: document perf --instances in the CLI reference (#391) 2026-10-15 17:17:51 +02:00
MarekSpodBiedry fdf397d7dc Refuse to create a variable collection with a name that is already taken (#416) 2026-10-15 17:17:44 +02:00
MarekSpodBiedry d1fcf69b7b Keep Mixed line height when Enter is pressed without typing (#439) 2026-10-15 17:17:30 +02:00
RealtaNuaandClaude Opus 5.5 49010614fd chore: credit Nael-Nathanael by GitHub profile name
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:16:10 +07:00
RealtaNuaandClaude Opus 5.5 778b8c6807 fix(ui): scrubbing the Scale panel's W and H scales live (#256)
W and H ignored scrubs. They now scale live in one undo step. The factor
field applies on release, because it is relative to 1.0 on every frame.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:58 +07:00
RealtaNuaandClaude Opus 5.5 1593b163fb fix(ui): scrubbing a prototype panel number sets it once (#256)
Delays, scroll offsets, overlay offsets and the overlay background alpha
were set on every frame of a scrub, an undo step each. They now set once,
when let go, like the custom size field.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:58 +07:00
RealtaNuaandClaude Opus 5.5 102429e6b5 fix(ui): scrubbing a colour picker field is one undo step (#256)
The RGB/HSL/HSB fields committed every frame of a scrub, so one drag left
about ten undo steps, and the alpha field ignored scrubs. Both now edit
live and commit when let go.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:55 +07:00
NetterSilverandClaude Haiku 5.5 6ce65b24f4 Keep region loops in ring order when adding a point to a reversed edge
Fixes #420

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:12:06 +00:00
NetterSilverandClaude Haiku 5.5 a4f4869443 Point duplicated page navigation at the copied frames
Fixes #419

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:10:00 +02:00
NetterSilverandClaude Haiku 5.5 a57976e88f Keep frame min/max size in .fig export without an auto layout parent
Fixes #457

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:00:16 +02:00
NetterSilverandClaude Haiku 5.5 3c28f605d1 Keep cleared range hyperlinks through FIG export and import
Fixes #458

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:55:48 +00:00
NetterSilverandClaude Haiku 5.5 76a67e7ee5 Point library style variable bindings at imported library variables
Fixes #462

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:47:58 +02:00
NetterSilverandClaude Haiku 5.5 330b237c9c Refresh linked layers when a library update changes only a style
Fixes #461

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:45:47 +00:00
NetterSilverandClaude Haiku 5.5 7689af50d6 Keep character-range text styles when publishing and placing components
Fixes #463

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:31:53 +02:00
tylerjenningsw d769320d45 test(canvas): grow the slow zoom-preview scene until it crosses the threshold (M8)
The browser-worker test assumed a fixed 2400x2400 glass scene renders
in at least 12 ms; a faster machine or renderer (the glass speedups)
would make it quick and the test fail. The scene now gets more and
more blurred rectangles until renders from scratch of the views the
test uses take three times the threshold.

Refs #295
2026-10-15 10:04:09 -04:00
Hamza 01c0b38669 fix(rest): keep a text hyperlink's destination whole on import (#459)
A hyperlink longer than 200 characters came back as a different 200-character
URL -- one that still looks like a working link -- and the import reported
nothing. It applied to a text layer's base style and to a character style
override alike.

The destination went through `clip_str`, which exists so that names and labels
from a file stay readable when they are quoted in a warning. Every other caller
is a name, a description, a style id or a layer type; a hyperlink is data, and
clipping it silently rewrote it. Two things say so: the FIG importer already
keeps the URL whole, so the two importers disagreed, and `rest::props` bounds
numbers and list lengths but no string beyond that label helper -- the text
content itself is not clipped either.

The reporter's integration test is included, with its assertions unchanged. Its
helper is restructured to return what it found rather than unwrap: the crate
denies `unwrap_used` and `panic`, and `clippy.toml` allows them only inside a
`#[test]` function, so the original shape failed the gate before it ran. Its
failure message now reports the input and output lengths.
2026-10-15 19:03:24 +05:00
NetterSilverandClaude Haiku 5.5 b61122ccca Fix eraser missing brush crossings narrower than the sample spacing
Fixes #421

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:28:43 +02:00
bflatastic 4fbe8d2976 Merge batch 3's FreeBSD font fix 2026-10-15 20:22:41 +07:00
bflatastic 8c731ddedc ci: the FreeBSD VM installs DejaVu, so text tests measure with a real font as on Ubuntu 2026-10-15 20:22:37 +07:00
NetterSilverandClaude Haiku 5.5 524b538908 Report changed=false when list indentation is capped
Fixes #425

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:21:05 +00:00
NetterSilverandClaude Haiku 5.5 72b826d65b Fix cross-axis arrows and drop slot on single-item wrapped lines
Fixes #422

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:17:21 +02:00
NetterSilverandClaude Haiku 5.5 0d125374d3 Don't record an undo step for an abandoned empty text box
Fixes #423

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:05:07 +00:00
bflatastic 745e599118 Merge #272 and #322 updates 2026-10-15 20:03:07 +07:00
bflatastic ffc01cbac9 Merge #322's update (main merged in; same change) 2026-10-15 20:03:01 +07:00
bflatastic d2e0ac40aa Merge #272's update (main merged in; same change) 2026-10-15 20:02:50 +07:00
bflatastic 4ed5190d7f Merge the updated community batch 3 2026-10-15 20:00:20 +07:00
bflatastic 3ce80b25ab Merge #300 fix: colour emoji on Linux 2026-10-15 19:59:45 +07:00
bflatastic ff3981de73 #300: emoji take a colour face before a style face without colour glyphs (DejaVu Sans on Linux drew them in one colour); the scale test allows faces wider than the em 2026-10-15 19:59:24 +07:00
bflatastic f32e8c5d2d Merge #306: M1: say why when the window can't open (#261) (@Barzabel) 2026-10-15 19:55:06 +07:00
bflatastic 4812b3ebfa Merge #317: M1: drop shadows under a background blur (#255) (@Barzabel) 2026-10-15 19:55:05 +07:00
bflatastic 2c51dbe4e3 Merge #322: fix(vector): keep the canvas still when entering vector edit mode and picking a region (@rcoenen) 2026-10-15 19:55:04 +07:00
bflatastic 261225340f Merge #308: M1: leaving Design ends the pen path and goes back to Move (@marcoarnulfo) 2026-10-15 19:55:03 +07:00
bflatastic 0444dc39a0 Merge #352: fix(ui): step through the font picker with Up and Down (#345) (@vxMacDev) 2026-10-15 19:55:02 +07:00
bflatastic 01463fb78e Merge #368: Variables export as a download in the browser (@mateusvillain)
# Conflicts:
#	crates/ui-egui/src/variables_panel.rs
2026-10-15 19:55:00 +07:00
bflatastic 3acffcd362 Merge #272: fix(ui): scroll a long pages list in its own area so the layers stay in view (@marcoarnulfo) 2026-10-15 19:54:10 +07:00
bflatastic 2ea8c3f86f Merge #338: M9: remove someone from a live session (@HaSh3003) 2026-10-15 19:54:09 +07:00
bflatastic 9ba207e900 Merge #400: Let batch steps reference ids created by earlier steps (@edivanteixeira) 2026-10-15 19:54:08 +07:00
bflatastic 6551f870b6 Merge #363: fix(engine): drag with the Text tool to make a fixed-width text box (#360) (@vxMacDev) 2026-10-15 19:54:07 +07:00
bflatastic 7a9e9de38c Merge #305: M1: save files automatically (#296) (@AganzeFelicite)
# Conflicts:
#	crates/ui-egui/src/idle_tests.rs
#	docs/mcp.md
2026-10-15 19:53:52 +07:00
Marco Arnulfo 1d5c9ae010 fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) 2026-10-15 14:44:31 +02:00
NetterSilverandClaude Haiku 5.5 8199623267 Include empty outlines in no-op shapeBuilder.merge response
Fixes #424

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 14:36:34 +02:00
midasmatterhorn 6ea957ec5d doc: runs of a file that reach past the text can't overflow a text edit
StyleRun start/end are usize taken straight from a .figcraft document.json
(nothing clamps them on load; only the REST and SVG importers normalize),
so TextData::replace_range's p - removed + inserted panics in a debug or
test build and wraps to zero in release, where normalize then drops the
run and the layer silently loses its formatting.

Shift the runs with saturating arithmetic; normalize drops whatever is
left. Regression test: text_runs_past_the_text_never_overflow — panics
at crates/doc/src/text.rs:605 on main, passes with the clamp.
2026-10-15 15:36:06 +03:00
bflatastic 51549f9a60 #306: say why the window can't open in the interface language
The message now comes from the UI crate (dialogs::window_failed) through
tr() and trf(), with entries in every catalog, and the app picks the
language from the preferences before the event loop starts, so the box
reads in it even though no frame was ever drawn. The commands to type stay
as written.
2026-10-15 19:36:00 +07:00
bflatastic 3d7191f4d3 people.toml: one entry per person after the merges 2026-10-15 19:35:24 +07:00
bflatastic d25e302a57 README: list the Czech and Indonesian drafts 2026-10-15 19:35:02 +07:00
bflatastic 4004aecaea Merge #477: M9: Indonesian draft catalog (@RevDonz)
# Conflicts:
#	contributors/people.toml
#	crates/ui-egui/src/cjk.rs
#	crates/ui-egui/src/i18n_tests.rs
#	docs/translating.md
2026-10-15 19:34:49 +07:00
NetterSilverandClaude Haiku 5.5 b29cc01d55 Fix perf failing with 'no card' on small instance counts
Fixes #394

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:34:16 +00:00
bflatastic 5a10623414 Merge #469: i18n: Czech (cs) UI catalog (draft) (@Losminek)
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/cmd/view.rs
#	docs/mcp.md
#	docs/translating.md
2026-10-15 19:34:05 +07:00
bflatastic be875da985 Merge #346: Set the native window identity to match the desktop entry (@IanHollow) 2026-10-15 19:33:07 +07:00
bflatastic 2a3309902a Merge #474: M7: find Unix user fonts under XDG_DATA_HOME (@IanHollow) 2026-10-15 19:33:06 +07:00
bflatastic f3e6b00e1a Merge #310: engine: refuse page.move for node ids that are not pages (@midasmatterhorn) 2026-10-15 19:33:02 +07:00
bflatastic 58e33f8fbb Merge #361: Make edit.findReplace replace case-insensitive matches (@Silvertree2010) 2026-10-15 19:32:55 +07:00
bflatastic ef375c623e Merge #326: M1: memory budgets for decoded images and a file's images (#203, #189) (@Barzabel) 2026-10-15 19:32:43 +07:00
bflatastic e4d0b92e55 Merge #300: Text: colour emoji draw from their own colour tables (#268) (@fede9234) 2026-10-15 19:32:34 +07:00
bflatastic 650049b469 Merge #281: fix(format): import explicit variable modes referenced by asset key (@notdezzi) 2026-10-15 19:32:32 +07:00
bflatastic 731bb0247c Merge #279: M6: .fig import lays out the layers of resized instances by their constraints (@marcoarnulfo) 2026-10-15 19:32:30 +07:00
bflatastic b13fbb52b3 Merge #332: M0.9: every hidden window answers control requests, not only the main one (#269) (@patlux) 2026-10-15 19:32:28 +07:00
bflatastic 48f75c92da Merge #313: M0.9: answer control requests while the window is hidden (#269) (@AganzeFelicite) 2026-10-15 19:32:27 +07:00
bflatastic f39b9a11fd Merge #280: Fix text edits rolling back prior document changes (@justinjohn0306) 2026-10-15 19:32:25 +07:00
bflatastic 120aa8a7a0 Merge #336: M1: keep a recovery copy until this run has its own (#187) (@Barzabel) 2026-10-15 19:32:23 +07:00
bflatastic 09ad21489b Merge #331: fix(recovery): keep the version history and branches in recovery copies (#185) (@vxMacDev) 2026-10-15 19:32:22 +07:00
bflatastic 0a2ed2fb65 Merge #303: Recovery: wait for the write in flight before an emergency save and before removing copies (#202) (@MarekSpodBiedry) 2026-10-15 19:32:20 +07:00
bflatastic 4b29532261 Merge #253: Avoid macOS file dialog reentrancy crashes (@justinjohn0306)
# Conflicts:
#	contributors/people.toml
2026-10-15 19:32:18 +07:00
bflatastic 22d5f17828 Merge main and the community batch into #306
# Conflicts:
#	apps/figcraft/src/main.rs
2026-10-15 19:27:31 +07:00
bflatastic 4dc7c1b59c #317: compute the backdrop once under a casting layer
With drop shadows the backdrop was read twice: before the content and
again after the shadows. The first read is only what backdrop effects
inside the content see, so it now runs only when the layer has such
descendants, and the content is copied for the shadow caster only when it
gets a layer blur, noise or texture. The output is unchanged.
2026-10-15 19:25:05 +07:00
NetterSilverandClaude Haiku 5.5 d0285d236e Keep a Rectangle in the smallest synthetic perf documents
Fixes #393

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 14:23:20 +02:00
bflatastic a89031eab2 #300: emoji take a colour face before the symbol fonts, which drew them in one colour on Windows 2026-10-15 19:21:37 +07:00
bflatastic 7a819dd190 Merge main and the community batch into #317 2026-10-15 19:20:55 +07:00
bflatastic 6291e43605 #322: use widgets::note for the vector edit hints
The wrapped hint label duplicated widgets::note (a wrapped secondary note
inside the panel's side padding); the two hints use it, and the copy and
the extra spacing after them go.
2026-10-15 19:20:32 +07:00
bflatastic cc80bd2a5d Merge main and the community batch into #322
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-15 19:17:03 +07:00
NetterSilverandClaude Haiku 5.5 908906f3f4 Close a layer's colour picker when the selection changes
Fixes #396

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:15:35 +02:00
bflatastic 9894ee0e65 #308: pressing Design again keeps the pen path going
Design is already the mode while the pen draws, so re-pressing it leaves
nothing: only the Pencil goes back to Move there, as before. Leaving for
Prototype or Inspect still ends the path.
2026-10-15 19:14:46 +07:00
bflatastic 62a10ea460 Merge main and the community batch into #308 2026-10-15 19:13:08 +07:00
bflatastic 8f58c39bd7 #352: keep the highlighted font in view in a shorter list
Where the window has less room the font list is shorter than its tallest,
and stepping down left the highlighted row cut off at the bottom. The
scroll now uses the list's height as last drawn.
2026-10-15 19:12:26 +07:00
Seun Samuel-Ipaye 2d78f40e60 Pen tool: a click's small wobble pulls out no handle 2026-10-15 08:11:40 -04:00
NetterSilverandClaude Haiku 5.5 f6437548ba Keep a styled layer's fill variable through .fig round trips
Fixes #399

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:08:55 +00:00
bflatastic b2d5dfecc1 #477: Indonesian for the alignment hint added since (draft) 2026-10-15 19:08:37 +07:00
bflatastic 62ed3452d6 #352: name the font list's height once
The list's max height and the arrow keys' scroll offset now share one
LIST_HEIGHT const.
2026-10-15 19:07:26 +07:00
bflatastic d96d03f452 Merge main and the community batch into #477
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
2026-10-15 19:06:54 +07:00
bflatastic cf06f2ca30 Merge main and the community batch into #352 2026-10-15 19:06:22 +07:00
bflatastic 42c8e762a0 #469: Czech for the alignment hint added since (draft) 2026-10-15 19:06:11 +07:00
bflatastic dd84097b6d #368: revoke a download's object URL after a delay
Revoking it right after the click can cancel the download in some
browsers; it now goes 40 s later on a timer.
2026-10-15 19:04:16 +07:00
bflatastic 8de536960e Merge main and the community batch into #469
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	docs/localization-parity.md
#	docs/roadmap.md
2026-10-15 19:03:31 +07:00
bflatastic 466bf6a813 Merge main and the community batch into #368
# Conflicts:
#	apps/figcraft-web/src/web.rs
#	crates/ui-egui/src/lib.rs
2026-10-15 19:02:58 +07:00
bflatastic e100c165fb #346: FreeBSD's desktop entry matches the window class there (the binary's name) 2026-10-15 19:02:40 +07:00
bflatastic 370d9f7adc Merge main and the community batch into #346 2026-10-15 19:00:48 +07:00
NetterSilverandClaude Haiku 5.5 fbe746a776 Cap startup operator problems at the latest 200
Fixes #409

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:58:59 +02:00
bflatastic a51b8d2271 Merge main and the community batch into #474
# Conflicts:
#	ROADMAP.md
2026-10-15 18:57:06 +07:00
bflatastic 68e130aae6 Merge main and the community batch into #310
# Conflicts:
#	crates/engine/src/panels_behaviour_tests.rs
2026-10-15 18:56:41 +07:00
bflatastic 50f1237617 #272: refuse page drops released outside the visible pages list
A page drag released over the Layers below could still reorder against a
row scrolled out of view, and a layer dropped there could move to that
page. The drop now counts only inside the list's visible area. The guard
is ported from #366 by Skyfuller1303 (@Skyfuller1303).
2026-10-15 18:56:27 +07:00
bflatastic a95f95ae37 #361: a replacement that only changes the case replaces every occurrence; the text's chars are collected once 2026-10-15 18:54:50 +07:00
NetterSilverandClaude Haiku 5.5 9853ffce61 Stop a guide drag released off the canvas from continuing on the next click
Fixes #431

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:54:07 +02:00
bflatastic 6215cae877 Merge main and the community batch into #361
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-15 18:52:02 +07:00
Andrii Volkov 2f4d3894b5 contributors: add my entry 2026-10-15 14:51:08 +03:00
Andrii VolkovandClaude Opus 5.5 bf506712ba fix(ui): show error toasts raised by a slow frame for their full 3 s
A toast's clock started from the time of the frame that first drew it. That
time is taken when the frame starts, before whatever raised the toast ran:
the Open dialog and a failed open blocked that frame for longer than 3 s, so
the error expired before it was ever seen and the open failed silently. The
clock now starts on the frame after the first one that draws the toast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:51:01 +03:00
bflatastic c230a97cca #326: decode at most the image crate's default 512 MiB per image; a full image cache drops its least recently used images, not all of them 2026-10-15 18:50:55 +07:00
bflatastic f2eb75da0b Merge main and the community batch into #272
# Conflicts:
#	crates/ui-egui/src/layers.rs
#	crates/ui-egui/src/layers_behaviour_tests.rs
2026-10-15 18:50:28 +07:00
NetterSilverandClaude Haiku 5.5 7d6a3ce137 Skip divider rows in Next page and Previous page
Fixes #413

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 11:49:53 +00:00
bflatastic 18381ff701 Merge main and the community batch into #326 2026-10-15 18:48:04 +07:00
bflatastic 3d112c4b8c #300: a colour PNG strike's size is checked before its pixels are allocated 2026-10-15 18:47:52 +07:00
bflatastic 18d4715cd9 Merge main and the community batch into #300
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-15 18:45:15 +07:00
bflatastic 25beadea65 #281: say the asset reference lookup is original code, read from the schema the file embeds 2026-10-15 18:44:29 +07:00
NetterSilverandClaude Haiku 5.5 ac7c91fc9c Reject layer IDs as targets of page.rename, delete and setBackground
Fixes #414

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:44:17 +02:00
bflatastic de1d2f77df Merge main, the community batch and #279 into #281
# Conflicts:
#	crates/format/src/fig/tests.rs
2026-10-15 18:44:08 +07:00
bflatastic aeeb1a7cde #338: forget a file's barred addresses when its session closes
The hub kept the addresses removed from every file it ever had open. The
entry now goes when the file is closed (everyone left) or removed from the
server, so a long-running public server doesn't keep collecting them.
2026-10-15 18:41:10 +07:00
bflatastic fa3d0873ed Merge main and the community batch into #279 2026-10-15 18:40:50 +07:00
bflatastic 4aa6ac6fcf Merge main, the community batch and #313 into #332
# Conflicts:
#	crates/ui-egui/src/control.rs
2026-10-15 18:40:35 +07:00
bflatastic b53967b423 #313: hidden-window control requests run inside the frame guard 2026-10-15 18:38:34 +07:00
bflatastic 8bcb0f78dd #338: drop the start/end feat marker comments 2026-10-15 18:37:52 +07:00
bflatastic e50bafbb75 Merge main and the community batch into #313
# Conflicts:
#	ROADMAP.md
#	apps/figcraft/src/main.rs
#	docs/roadmap.md
2026-10-15 18:37:11 +07:00
bflatastic 54f9212a2f Merge main and the community batch into #338 2026-10-15 18:35:05 +07:00
bflatastic 477f019737 #280: a drag released off the window ends where the pointer was last seen, not mid-canvas; the test follows #278 2026-10-15 18:34:23 +07:00
bflatastic 41d2ef688e Merge #312: perf(canvas): refresh full-viewport previews during zoom (M8) (@tylerjenningsw) 2026-10-15 18:33:29 +07:00
bflatastic 4378308c30 Merge main into #312
# Conflicts:
#	ROADMAP.md
2026-10-15 18:30:37 +07:00
bflatastic e11a182afd #400: a nested batch keeps its own step references
The outer batch passes a command.batch step on as written, so the inner
steps' `$step` references point at the inner batch's steps. The docs say
that `$step` is a reserved key in step params.
2026-10-15 18:30:05 +07:00
Marco Arnulfo 34565964b0 Keep nested frames out of the ready-for-dev list (fixes #455) 2026-10-15 13:29:22 +02:00
bflatastic 4aadfa0439 README: list the Turkish catalog 2026-10-15 18:28:43 +07:00
bflatastic 394b609ec4 Merge #263: M9: Turkish catalog (reviewed) (@yyamandevrim)
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	docs/localization-parity.md
#	docs/translating.md
2026-10-15 18:28:32 +07:00
bflatastic 29954e6067 Merge main and the community batch into #280 2026-10-15 18:28:18 +07:00
bflatastic 272973e6f9 Merge #257: M9: Russian interface translation (@VanTan666) 2026-10-15 18:27:48 +07:00
bflatastic e15242f506 Merge main, the community batch, #303 and #331 into #336
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:27:43 +07:00
bflatastic 6d9024e0d0 Merge main and the community batch into #400 2026-10-15 18:27:37 +07:00
Rob Coenen 121ba72957 Merge remote-tracking branch 'origin/main' into vector-edit-canvas-stays-put
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-15 07:26:11 -04:00
bflatastic adf96ca2e7 #363: a dragged text box keeps the drag's width and height
A drag with the Text tool now makes a fixed-size box (the drag's size, at
least one line tall) instead of a fixed width with an auto height.
2026-10-15 18:25:06 +07:00
bflatastic 0757800b3d Merge main, the community batch and #303 into #331
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:24:31 +07:00
BFlat 102404e8c3 Community batch 2: 20 reviewed fixes (#510)
Lands 20 reviewed community PRs with their own commits: #314 #337 #330 #297 #301 #282 #284 #254 #475 #384 #358 #472 #471 #339 #328 #288 #343 #344 #353 #290, plus a test fix on #297's branch so its retry tests run on Windows.
2026-10-15 18:23:50 +07:00
bflatastic 9b1c79277a Merge main and the community batch into #363 2026-10-15 18:22:43 +07:00
bflatastic 379e4b5c55 Merge main and the community batch into #303
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:21:15 +07:00
NetterSilverandClaude Haiku 5.5 b3b6b14eb4 Anti-alias rectangular frame clips on fractional device edges
Fixes #428

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:18:59 +02:00
NetterSilverandClaude Haiku 5.5 be56ca2836 Keep CMYK conversion inside PDF vector pattern tiles
Fixes #426

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:18:33 +00:00
bflatastic b14a332405 #257: keep only Spanish in the must-be-complete list, so new UI strings don't require Russian too 2026-10-15 18:14:19 +07:00
bflatastic 65c9352c5c Merge main and the community batch into #257
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	docs/localization-parity.md
#	docs/roadmap.md
2026-10-15 18:13:34 +07:00
bflatastic 69de1ee36f #253: a dialog's result goes to the file and window it was opened for; dialogs are parented to the main window 2026-10-15 18:12:35 +07:00
NetterSilverandClaude Haiku 5.5 9687c0ffc0 Draw layer blurs up to the documented 10,000 radius
Fixes #430

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:12:25 +00:00
bflatastic b75e5bea3b #305: auto-save after a pause, off the UI thread
Nothing is collected while auto-save is off. The wait restarts whenever an
unsaved file changes, so continuous editing doesn't save every few seconds,
and the file is encoded on a thread of its own, then written.
2026-10-15 18:11:54 +07:00
bflatastic 8616f3d1c5 #305: save a file from a snapshot encoded elsewhere
FileSnapshot takes what saving writes, encodes it (thumbnail and all) on any
thread, and writes it back as of the snapshot: edits since stay unsaved, and
bytes from before another save never overwrite the newer file.
2026-10-15 18:11:52 +07:00
BFlat 3ddb19edee Community batch 1: 22 reviewed fixes (#507)
Lands 22 reviewed community PRs with their own commits: #271 #293 #372 #386 #327 #311 #265 #276 #444 #275 #388 #390 #318 #355 #324 #375 #283 #277 #262 #340 #302 #294.
2026-10-15 18:03:38 +07:00
bflatastic 8b1613c7ee Merge main and the community batch into #253 2026-10-15 18:00:57 +07:00
bflatastic 4f12bf1e00 Merge #297 fix: recovery retry tests run on Windows 2026-10-15 18:00:10 +07:00
bflatastic 471694dc48 #297: recovery retry tests fail the copy with a folder in its place, so they also run on Windows 2026-10-15 18:00:02 +07:00
bflatastic fc7556ae7f Merge main and the community batch into #253
# Conflicts:
#	contributors/people.toml
2026-10-15 17:58:35 +07:00
bflatastic bebe509a25 Merge main and the community batch into #305
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-15 17:58:14 +07:00
bflatastic 55eaeb8709 Merge #290: M1: open .figcraft files from Finder, Open With and the Dock on macOS (@marcoarnulfo) 2026-10-15 17:54:25 +07:00
bflatastic 3c52dad774 Merge #353: engine: long digit strings no longer overflow object.opacityKeys (@midasmatterhorn) 2026-10-15 17:54:23 +07:00
bflatastic 5a57388b8d Merge #344: doc: name counters stop at u64::MAX instead of overflowing (@midasmatterhorn) 2026-10-15 17:54:21 +07:00
bflatastic 7a3d2870c5 Merge #343: collab: refuse sequence numbers a host cannot advance (@midasmatterhorn) 2026-10-15 17:54:20 +07:00
bflatastic cc0c3fb10a Merge #288: Add Japanese labels for templates, recent files, and editor controls (@haranishi)
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/locales/ja.toml
#	docs/localization-parity.md
2026-10-15 17:54:17 +07:00
bflatastic b71084f366 Merge #328: packaging: RPM declares the glibc 2.35 floor, so RHEL 9 refuses it (@tydude001) 2026-10-15 17:53:14 +07:00
bflatastic 5dc213820b Merge #339: M1: cursor and double-click tests scan the system fonts themselves (#333) (@Barzabel) 2026-10-15 17:53:12 +07:00
bflatastic f518d87b71 Merge #471: M4: hanging punctuation test holds for fonts that kern with x offsets (@rcoenen) 2026-10-15 17:53:10 +07:00
bflatastic 1dae6a439d Merge #472: M1: Cmd+R rename test sends Ctrl+R only where Ctrl is the command key (@rcoenen) 2026-10-15 17:53:08 +07:00
bflatastic 022821211f Merge #358: Replace every occurrence in edit.findReplace when replacement contains find (@Silvertree2010) 2026-10-15 17:53:06 +07:00
bflatastic 03716da731 Merge #384: Reject negative or fractional index in effect.set, export.update and export.remove (@Silvertree2010) 2026-10-15 17:53:04 +07:00
bflatastic 2420791015 Merge #475: Refuse negative or fractional indexes in page.select and doc.switch (@Silvertree2010) 2026-10-15 17:53:02 +07:00
bflatastic 8ae567c06b Merge #254: fix(engine): read the documented gradient shorthand on every route (#252) (@pcastro93) 2026-10-15 17:53:00 +07:00
bflatastic a25952dc60 Merge #284: fix(format): exclusive unique staging files for atomic save/export (@notdezzi) 2026-10-15 17:52:58 +07:00
bflatastic 4d93274eb1 Merge #282: fix(engine): roll back failed commands during interactions too (@notdezzi) 2026-10-15 17:52:56 +07:00
bflatastic a663e97a5d Merge #301: Keep the redo steps when a failed command batch rolls back (#196) (@MarekSpodBiedry) 2026-10-15 17:52:54 +07:00
NetterSilverandClaude Haiku 5.5 8b0649da3a Drop the draw or marquee gesture a canvas double-click starts
Fixes #432

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:52:47 +00:00
bflatastic 097110b359 Merge #297: Count a recovery revision as copied only after its write succeeds (@Silvertree2010)
# Conflicts:
#	contributors/people.toml
2026-10-15 17:52:15 +07:00
bflatastic 1778b2317c Merge #330: M1: deeply nested layers don't run the layout out of stack (#210) (@Barzabel) 2026-10-15 17:51:46 +07:00
bflatastic e5405f4eb6 Merge #337: M3: renaming a style folder no longer closes the app (@Barzabel) 2026-10-15 17:51:44 +07:00
bflatastic 23bb8040ad Merge #314: M1: creating a variable collection no longer closes the app (#224) (@Barzabel) 2026-10-15 17:50:49 +07:00
NetterSilverandClaude Haiku 5.5 3293a12542 Fix view jump when returning after a pan released over a panel
Fixes #433

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:48:25 +02:00
NetterSilverandClaude Haiku 5.5 42e6f267b8 Reveal the comment's page when opening it via ui.set commentOpen
Fixes #435

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 10:43:25 +00:00
NetterSilverandClaude Haiku 5.5 831b8fed43 Fix panic drawing a selected guide label in a zero-width canvas
Fixes #434

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:42:05 +02:00
NetterSilverandClaude Haiku 5.5 72a3c8522f Keep consecutive spaces in recent file accessible names
Fixes #436

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:37:55 +02:00
NetterSilverandClaude Haiku 5.5 ec9b1197c9 Save the PDF from File > Export frames to PDF
Fixes #437

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:37:47 +00:00
NetterSilverandClaude Haiku 5.5 43e6cba2ef Keep committed line height and letter spacing in the text style editor
Fixes #438

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:26:22 +02:00
NetterSilverandClaude Haiku 5.5 8e56820168 Fix variable.update scopes: null not resetting to all supported
Fixes #443

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 10:14:49 +00:00
NetterSilverandClaude Haiku 5.5 376d524a57 Keep the colour picker and open settings on their item after a removal
Fixes #442

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:09:40 +02:00
NetterSilverandClaude Haiku 5.5 61bc027072 Fix MCP layer screenshots exceeding maxSize
Fixes #445

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:56:32 +02:00
NetterSilverandClaude Haiku 5.5 4cf1da167c Default CLI render and export to the first non-divider page
Fixes #446

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:56:15 +00:00
NetterSilverandClaude Haiku 5.5 dee13aefc5 Keep hidden export roots from enlarging the exported image
Fixes #447

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:48:39 +02:00
NetterSilverandClaude Haiku 5.5 7dd71bc2da Brace variable names in expression text that would not read back
Fixes #448

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:45:13 +00:00
NetterSilverandClaude Haiku 5.5 ef10edfe29 Skip missing segments when starting a region loop walk
Fixes #449

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:39:06 +02:00
NetterSilverandClaude Haiku 5.5 c3330c4260 Leave missing vertices out of region point queries
Fixes #451

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:33:53 +00:00
NetterSilverandClaude Haiku 5.5 e4b4fb0cac Fix paint bucket leaving a face filled when its region is duplicated
Fixes #450

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:32:07 +02:00
NetterSilverandClaude Haiku 5.5 4f9cbf702f Merge text runs made identical by style updates
Fixes #452

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:23:44 +02:00
NetterSilverandClaude Haiku 5.5 6a2eeec928 Make empty ranges link and unlink text styles on all characters
Fixes #453

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:16:00 +02:00
NetterSilverandClaude Haiku 5.5 c6fdf9bd4c Keep layer text and string defaults unchanged in rem code output
Fixes #454

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:08:53 +02:00
NetterSilverandClaude Haiku 5.5 e8a30df944 Give case-colliding variant properties distinct data attributes
Fixes #456

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:57:13 +02:00
Patrik LermonandClaude Opus 5.5 ee2640d18e Swedish interface translation (sv)
Adds Swedish (Svenska) as an interface language: 2165 strings, written from the English source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:34:20 +02:00
bflatastic d337b34380 Merge #340: M9: a compaction cut short closes the file instead of confirming lost edits (#183) (@Barzabel) 2026-10-15 15:10:38 +07:00
bflatastic 731b7e356a Merge #294: fix(desktop): Windows' first preferred language picks the UI language (#291) (@fede9234)
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-15 15:10:19 +07:00
bflatastic a8604adbca Merge #302: fix(server): keep each team library version in its own file (#206) (@MarekSpodBiedry) 2026-10-15 15:10:14 +07:00
bflatastic 8959030255 Merge #262: M7: follow system theme from the first frame (@VanTan666)
# Conflicts:
#	contributors/people.toml
#	docs/ui-parity.md
2026-10-15 15:10:11 +07:00
bflatastic 1d71fb4044 Merge #277: fix(pen): show the bezier handles while drawing (#256) (@Nael-Nathanael) 2026-10-15 15:09:44 +07:00
bflatastic 8c8c00fcba Merge #283: fix(engine): Alt-drag copies snap to their original; Cmd+D repeats the step (#256) (@Nael-Nathanael) 2026-10-15 15:09:41 +07:00
bflatastic 990cb4993f Merge #375: Copy and paste through the system clipboard in the browser (@mateusvillain) 2026-10-15 15:09:38 +07:00
bflatastic b321ff48d0 Merge #324: fix(home): keep a long path from drawing over the file name in Open files (@patlux) 2026-10-15 15:09:05 +07:00
bflatastic ec83040535 Merge #355: M7: the Small caps sample in Type settings draws without a box (@StrajnarFilip) 2026-10-15 15:09:01 +07:00
bflatastic 55a476053a Merge #318: fix(home): recent files notice files deleted while the home screen is open (@rcoenen) 2026-10-15 15:08:58 +07:00
bflatastic eededda4fd Merge #390: Keep text selection highlight inside the box at the end of the text (@Silvertree2010) 2026-10-15 15:08:55 +07:00
bflatastic 92d00ca04a Merge #388: Keep corner resize cursors diagonal on wide and tall layers (@Silvertree2010) 2026-10-15 15:08:52 +07:00
bflatastic b93ace5cbf Merge #275: fix(ui): gradient type icons that show their gradient (#256) (@Nael-Nathanael) 2026-10-15 15:08:48 +07:00
bflatastic f4a7b39731 Merge #444: Fix Send to back leaving layers above siblings across frames (@Silvertree2010)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-15 15:08:45 +07:00
bflatastic f9b7dcf9d1 Merge #276: fix(ui): disable alignment when the selection can't be aligned (#256) (@Nael-Nathanael) 2026-10-15 15:08:11 +07:00
bflatastic 663c203914 Merge #265: M7: a slice's export preview shows the page content under it (@ahmed-226) 2026-10-15 15:08:05 +07:00
bflatastic 36c6cf6bd2 Merge #311: fix(pages): inherit the current page background when adding pages (M1) (@tylerjenningsw)
# Conflicts:
#	ROADMAP.md
2026-10-15 15:08:00 +07:00
bflatastic 23b82e1f00 Merge #327: M1: SVG export keeps a nested frame's fill and stroke inside its parent's clip (@marcoarnulfo) 2026-10-15 15:07:22 +07:00
bflatastic 4ac1d80fe8 Merge #386: Outline stroke on every selected layer, not just the first (@Silvertree2010) 2026-10-15 15:07:19 +07:00
bflatastic 2f0369a7ad Merge #372: fix(engine): new text is readable on the frame it is drawn in (#362) (@vxMacDev) 2026-10-15 15:07:17 +07:00
bflatastic 457c688872 Merge #293: M1: a press on a corner handle resizes instead of picking the corner-radius handle (@marcoarnulfo) 2026-10-15 15:07:14 +07:00
bflatastic 0b364ae1fe Merge #271: M7: double-clicking a page in the Pages list no longer closes the app (@marcoarnulfo) 2026-10-15 15:07:11 +07:00
Andrii VolkovandClaude Opus 5.5 9eadb37622 perf(ui): look for CJK names in edits, not on every pan
Each frame checks the active document's names for CJK scripts, to install
faces for them. The check was keyed on the revision, which pans and zooms
bump too, so every frame of a pan read every layer name. In a 900 000-layer
file that was ~70 ms a frame: panning ran at ~14 fps whatever the canvas cost.

It now keeps the document version it read: the same version (a pan, a zoom)
is skipped, and a new one reads only the layers that changed since
(`Document::changed_ids`, sized by the edit), or every name past 10 000
changes. On that file the UI frame while panning went from ~71 ms to ~3 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-15 10:47:23 +03:00
Reva Doni AprilioandClaude Opus 5.5 627933ae06 M9: Indonesian draft catalog
Adds crates/ui-egui/locales/id.toml, a draft Indonesian catalog covering
every UI string (1,577 keys), command label (557) and menu wording (31),
machine-drafted and marked "draft — needs native review".

Indonesian keeps a noun unchanged after a count, so plural entries give
only `other` and plural_one needs no change. Preferences ▸ Language lists
Bahasa Indonesia; System maps id / id_ID to it (new assertion in
i18n_tests.rs) and `id` joins the cjk can-show list. Docs updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:39:08 +07:00
Andrey 8c74c72b50 Merge branch 'main' into fix/261-startup-failure-dialog 2026-10-15 10:15:53 +03:00
Andrey 17a3e2b6d6 Merge branch 'main' into fix/224-variables-collection-crash 2026-10-15 10:15:49 +03:00
Andrey e4126949bf Merge branch 'main' into fix/255-shadow-behind-blur 2026-10-15 10:15:46 +03:00
Andrey 15ca8d4881 Merge branch 'main' into fix/203-189-image-memory-budgets 2026-10-15 10:15:42 +03:00
Andrey 704831830e Merge branch 'main' into fix/210-deep-nesting-stack 2026-10-15 10:15:39 +03:00
Andrey de30e34538 Merge branch 'main' into fix/187-keep-recovery-until-recopied 2026-10-15 10:15:36 +03:00
Andrey 4e5ff2bd46 Merge branch 'main' into fix/style-folder-rename-focus 2026-10-15 10:15:32 +03:00
Andrey 6fbf3ca9aa Merge branch 'main' into fix/333-engine-tests-scan-fonts 2026-10-15 10:15:29 +03:00
Andrey e41e12324d Merge branch 'main' into fix/183-compaction-keeps-edits 2026-10-15 10:15:26 +03:00
NetterSilverandClaude Haiku 5.5 7f2f8ee09a Refuse negative or fractional indexes in page.select and doc.switch
Found while testing crates/engine/src/cmd/page.rs:50

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:02:06 +02:00
IanHollow 611fb324b9 M7: find Unix user fonts under XDG_DATA_HOME
Assisted-by: GPT-6
Signed-off-by: IanHollow <[email protected]>
2026-10-14 22:59:17 -07:00
Rob Coenen 1b67ac1e6d Merge remote-tracking branch 'origin/main' into claude/wonderful-neumann-adc42b 2026-10-15 01:15:53 -04:00
LosminekandClaude Opus 5.5 49a2c643c6 i18n: add Czech (cs) UI catalog as a draft
Adds crates/ui-egui/locales/cs.toml with all 2165 entries (ui, commands,
menu), marked "draft — needs native review" and kept out of COMPLETE.
Plurals use one/other only; the other form is worded count-neutral so it
reads correctly for 2-4 as well. Lists cs in the pref.language help text,
adds a cs_CZ.UTF-8 detection test and updates the localization docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:27 +02:00
Rob CoenenandClaude Opus 5.5 996852d15c M4: hanging punctuation test holds for fonts that kern with x offsets
On macOS without craft-fonts the default face is Helvetica Neue, whose
kerning comes out of the shaper partly as an x offset (e.g. on the "e" of
"here."), so a glyph's x + advance is not its pen end. The layout was
right (the full stop's pen sits exactly on the edge); the test assumed no
offsets. Each hanging line is now compared with the same line set flush:
every glyph must move by exactly the hanging mark's advance, and the flush
right line must end on the edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:55:43 -04:00
NetterSilverandClaude Haiku 5.5 1199f690ad Fix Send to back leaving layers above siblings across frames
Found while testing crates/engine/src/cmd/arrange.rs:327-330

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:12:21 +02:00
Rob Coenen 099465212e Merge remote-tracking branch 'origin/main' into vector-edit-canvas-stays-put
# Conflicts:
#	crates/ui-egui/src/home_tests.rs
2026-10-14 23:57:57 -04:00
Andrey 8482c3e85b M7: home tests wait for the recent files reader instead of a fixed frame count (#351) (#354) 2026-10-15 05:53:18 +02:00
skylar taylor-barrickandskylartaylor 3a1aeb7a4b ci: add comfortable runtime limits to unbounded jobs (#397)
Co-authored-by: skylartaylor <[email protected]>
2026-10-15 05:15:58 +02:00
Edivan TeixeiraandClaude Opus 5.5 13691c1cc0 Let batch steps reference ids created by earlier steps
Inside command.batch, `{"$step": n}` anywhere in a step's params is
replaced by the `id` in step n's result before the step runs
(`{"$step": n, "field": "..."}` picks another field). A nested tree,
such as a frame with children, now takes one batch and one undo step,
not one round-trip per level with ids copied between calls by hand.

Malformed references (forward or self, negative or fractional index,
missing or non-string field, extra keys) fail the batch with BadParams
naming the step, and the existing group machinery rolls it back. The
params walk is depth-capped. Steps are journalled with resolved params.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:43:31 -03:00
NetterSilverandClaude Haiku 5.5 d1430e3054 Keep text selection highlight inside the box at the end of the text
Fixes #365

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:56:27 +02:00
NetterSilverandClaude Haiku 5.5 fc6f52e852 Keep corner resize cursors diagonal on wide and tall layers
Fixes #370

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:50:00 +02:00
NetterSilverandClaude Haiku 5.5 3c00ea6440 Outline stroke on every selected layer, not just the first
Fixes #378

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:41:17 +02:00
NetterSilverandClaude Haiku 5.5 87b9a3a9ce Reject negative or fractional index in effect.set, export.update and export.remove
Found while testing crates/engine/src/cmd/edit.rs:256 (effect.set), :305 (export.update), :317 (export.remove)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:08:36 +00:00
vxMacDev 12d4dbfbc9 fix(engine): new text is readable on the frame it is drawn in (#362)
The colour of new text in the app was chosen from the page alone, so
on a dark page text drawn in a white frame (a new frame's default) was
white and invisible. readable_text_fills now looks at what the text is
drawn on: the nearest layer from its parent out whose top visible fill
is a mostly opaque solid colour, else the page. The Text tool and
text.create / node.create pass the text's parent.

The two tests that asserted the page-only rule in a frame now expect
black text in a white frame; new cases cover dark, unfilled, nested,
faint and stacked fills, and the Text tool.
2026-10-14 18:35:21 -05:00
Mateus Villain 3edb15c417 M1: copy and paste through the system clipboard in the browser
The web build's Services never wrote to the clipboard, so Cmd+V after
Cmd+C got the paste event with whatever text the clipboard held before
and made a text layer of it instead of pasting the copied layers.
WebServices now writes copies (text and PNG) through egui's output,
which eframe hands to the browser clipboard during the copy event.
2026-10-14 20:26:18 -03:00
Mateus Villain 01a0f4f332 M3: variables export as a download in the browser
The web build has no save dialogs, so Export… ▸ Variables JSON / CSS
custom properties returned silently. Services gains downloads() and
download(name, bytes); the browser hands the file over through a Blob
link, and the variables panel uses it where the platform saves by
downloads.
2026-10-14 20:01:53 -03:00
vxMacDev b87d2e476f fix(engine): drag with the Text tool to make a fixed-width text box (#360)
The Text tool made its text on the press and ignored the drag, so a
drag gave auto-width text like a click. The new text now stays in a
TextBox gesture while pressed: past the drag slop it becomes a box as
wide as the drag, its top-left at the drag's, with a fixed width and a
height that follows its text. A click, or a drag within the slop, still
makes auto-width text.
2026-10-14 17:29:09 -05:00
NetterSilverandClaude Haiku 5.5 2aa3ca3c45 Make edit.findReplace replace case-insensitive matches
Found while testing crates/engine/src/cmd/edit.rs:962-977

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 22:26:37 +00:00
NetterSilverandClaude Haiku 5.5 c02e864873 Replace every occurrence in edit.findReplace when replacement contains find
Found while testing crates/engine/src/cmd/edit.rs:975-981

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:22:29 +00:00
Rob CoenenandClaude Opus 5.5 3c03f370f9 M1: Cmd+R rename test sends Ctrl+R only where Ctrl is the command key
On macOS egui never sends `ctrl` together with `command`; Ctrl comes alone there
and Ctrl+R isn't a shortcut (rename is Cmd+R or F2), so case 1 failed on every
macOS run. The keymap is right; the test now presses Ctrl+R only off macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:07:05 -04:00
vxMacDev 8efb66ba19 fix(ui): step through the font picker with Up and Down (#345)
Up and Down now move a highlight through the family list, starting
from the current family and scrolling to keep it in view. The text
panel previews the highlighted family on the canvas, as it does for a
hovered font style, and Enter applies it. Escape leaves the text as it
was.
2026-10-14 15:24:40 -05:00
midasmatterhorn 65e52aa7b9 engine: long digit strings no longer overflow object.opacityKeys
opacity_from_digits multiplied and added on every character of its input
string before the one-or-two-digit check, so "9999999999" overflowed u32 on
the way (panic in debug builds, wrap in release). The scan now steps n with
checked_mul/checked_add and stops (None) at the first character the number
cannot take, so a long digit string is refused like any other bad input.

Test: opacity_digits (engine keymap) covers the ten-digit string and the
command's BadParams refusal.
2026-10-14 23:22:41 +03:00
IanHollow 212438a6e3 Set the native window identity to match the desktop entry
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-14 13:12:38 -07:00
HaSh3003 f387d3f464 M9: translate Remove from session
The menu item and the collab.removePerson label in es, de, fr, ja, ko, pt-BR and zh-Hans (es must be complete).
2026-10-14 23:00:42 +03:00
Barzabel 90a4606c65 M9: a compaction cut short closes the file instead of confirming lost edits (#183) 2026-10-14 22:53:02 +03:00
midasmatterhorn 245837ff27 doc: name counters stop at u64::MAX instead of overflowing
Document::next_name summed max + 1 over numbers parsed from existing layer
names, and bump_name did the same for the duplicate-rename path: a document
(or peer, or node.set) holding a layer named "Frame 18446744073709551615"
overflowed both (panic in debug, wrap to a wrong name in release). next_name
now collects the numbers it sees and, when the counter is spent, picks the
lowest free number (mirrors new_style_id); bump_name only bumps when the
number can step, else falls to its existing " 2" branch.

Tests: next_name_never_overflows (doc) and
duplicating_a_layer_named_past_the_counter_does_not_overflow (engine panel
behaviour).
2026-10-14 22:52:15 +03:00
midasmatterhorn ca2f5dbcee collab: refuse sequence numbers a host cannot advance
The welcome's seq and processed.last_batch are the host's word and were used
unchecked: accepted computed self.seq + 1 and queue bumped next_batch, so a
host sending u64::MAX overflowed the client (panic in debug, wrap in release).
Both counters now step with checked_add/saturating_add, and a sequence that
cannot advance is refused as a protocol error (the established rejoin path).

Test: hostile_sequence_numbers_do_not_overflow_the_client.
2026-10-14 22:47:51 +03:00
Barzabel 5e14632207 M1: cursor and double-click tests scan the system fonts themselves (#333) 2026-10-14 22:39:17 +03:00
HaSh3003 fa0365470e M9: remove someone from a live session
Right-click a person's avatar ▸ "Remove <name> from session", or
`collab.removePerson {client}`, when you host the session or own the file
on a server. The hub closes their connection as the end of the session for
them (close 1001: they keep their copy and don't reconnect) and bars their
address from that file while it runs, so the code or link they hold no
longer gets them in; everyone else stays connected.

Protocol addition within version 2: `Admin::RemovePeer {file, client}` on
an open hosting connection with the file's owner token (or the server
key). Older servers say they can't.

Tests: loopback (removed, can't rejoin, others stay), engine (host can,
guest can't, bad ids refused), codec round trip; the collab junk sweep
covers the new command.
2026-10-14 22:32:56 +03:00
Barzabel b8043cc2ad M3: renaming a style folder no longer closes the app 2026-10-14 22:23:51 +03:00
Patrick Wozniak 30478894fd M0.9: a window on screen draws requests for it while the main window is hidden (#269)
Requests reach the UI through the main window's logic. A request for
another window is answered in that window's frame, and the main window's
frame (`drain`) asks that window to draw. While the main window is
hidden, only its logic runs and nothing asked: an idle window on screen
draws no frames, so the request waited until something else repainted
the window, or timed out after 60 s.

`drain_hidden` now asks a window on screen that a request waits for to
draw. A hidden window isn't asked: it can't draw, and the main window's
logic already answers what it can.
2026-10-14 21:17:42 +02:00
Filip Strajnar 6bd87bccc6 M7: the Small caps sample in Type settings draws without a box
The Small caps button in Type settings labelled itself "Aɢ", but the
small-capital G (U+0262) is missing from egui's default fonts, which the
UI uses whenever Inter isn't built in, so the button showed "A" and a box.
The sample is now a capital followed by smaller capitals laid out from
ordinary letters every UI font has. A test checks the UI fonts draw every
letter of the sample.
2026-10-14 21:12:39 +02:00
Barzabel ac8a0a112c M1: keep a recovery copy until this run has its own (#187) 2026-10-14 22:10:49 +03:00
Patrick Wozniak 64eae8c8c5 M0.9: every hidden window answers control requests, not only the main one (#269)
`drain_hidden` answered only requests for the main window, and only
while that window was hidden. A request addressed to another window
(`window.new`, `window` param) that is minimised or covered still waited
for that window's frame and timed out after 60 s, whether the main
window was on screen or not. An agent that works in its own window lost
it as soon as the window went behind others or to another Space.

`drain_hidden` now looks at the target window of each request: if that
window is hidden (`ViewportInfo::visible() == Some(false)` for its
viewport) and the method needs no frame, it is answered from the main
window's logic with the target window's state swapped in, as while it
draws, so it acts on that window's file. `draw` and the new
`windows::as_window` share the swap (`enter`, `adopt`, `leave`). The
main window's logic runs on every request (the control server asks for
a repaint), so this works while the main window is on screen too. What
needs a frame keeps waiting, as before; a request for a window that
doesn't exist is an error at once instead of waiting for a frame.
2026-10-14 20:45:19 +02:00
vxMacDev b821b6f8f4 fix(recovery): keep the version history and branches in recovery copies (#185)
Recovery copies were written with plain `save_bytes`, so a file
recovered after a crash came back without its version history and
branches, even though `file.recover` reads both. Copies now go through
`save_bytes_with_history` like a normal save, with each open branch
tab's latest edits (`branches::current`), since `sync` only puts those
in before the next command.

A copy was also only redone when the main file's revision changed, and
a file with edits only in a branch tab didn't count as changed. Both
checks now include the file's branch tabs.
2026-10-14 13:41:17 -05:00
Barzabel a3048770b6 M1: deeply nested layers don't run the layout out of stack (#210) 2026-10-14 21:37:55 +03:00
tydude001andClaude Opus 5.5 1c1b699598 packaging: RPM declares the glibc 2.35 floor, so RHEL 9 refuses it
FigCraft's RPM declares no glibc floor, though the deb has `libc6 (>= 2.35)`. On Rocky Linux 9
(glibc 2.34) the RPM installs, and then `figcraft-cli` fails with ``/lib64/libm.so.6: version
`GLIBC_2.35' not found``.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 13:35:50 -05:00
Rob CoenenandClaude Opus 5.5 3e777d2485 M7: home screen test waits for the recent-file reader
The first-run test settled the home screen with a fixed 12 frames, but
the recent files are looked at on a thread of their own and a card shows
no name until that thread has answered. On a busy CI runner it hadn't
yet, so "ridgeline" was still a grey placeholder and the test failed
(seen on this PR and on codex/issue-250-async-dialog). settle() now waits
until every recent file has been looked at, as home_recents_tests does.
Checked with a reader slowed to 200 ms: the old test fails at the same
line as CI, the new one passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:30:10 -04:00
Marco Arnulfo ebf6045184 M1: SVG export keeps a nested frame's fill and stroke inside its parent's clip (fixes #325) 2026-10-14 19:40:08 +02:00
Barzabel 15ae2e9115 M1: memory budgets for decoded images and a file's images (#203, #189) 2026-10-14 20:36:47 +03:00
Patrick Wozniak 639c4d7db0 Home: test the file row layout on Open files
Recent files are cards since #249, so the row test now opens two
documents with synthetic paths and checks the Open files list: the row
that fits shows name and path in full; the long one shows the name cut at
its end, then the path cut at its start, at least 12 px apart, and both in
full on hover. Without the row fix the name shows as "Quar…" under the
path.
2026-10-14 19:05:12 +02:00
Patrick Wozniak f1e0a758d1 Home: file rows no longer draw the path over the name
The Open files and Recent files rows painted the detail (the file's path)
right-aligned at its full width and only then elided the name into
whatever was left, with a floor of 40 px. A path wider than the row ran
straight over the name, and the name itself shrank to "Mae…".

The row is now laid out name first: the name keeps its full width when
both fit, otherwise at least 60% of the row; the detail gets the room
left after a 16 px gap and is cut at its start ("…/02-technik/KNX/
file.figcraft"), which keeps the file and its nearest folders. A detail
with less than 48 px of room is left out. Rows of Recents' own lists show
name and path in full on hover when either was cut.

The new widgets::elide_start_font mirrors elide_font, keeping the end.
2026-10-14 18:59:47 +02:00
Rob CoenenandClaude Opus 5.5 9404226235 M4: vector edit mode keeps the canvas still under the pointer
With no points picked, the Design panel's hint ("Select points to edit
their position, radius and mirroring") didn't wrap and made the canvas
49 points wider, so entering vector edit mode moved the view by about 25
points and picking a region moved it back. The jump came in the middle
of the click that picked the region: with the smallest pointer movement
the release landed elsewhere on the page and the region was dragged by
that distance (about 47 units at 70%). Both edit mode hints now wrap.
Regression test: the canvas rectangle stays the same entering edit mode,
picking a region and switching to the width tool.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:35:06 -04:00
Rob CoenenandClaude Opus 5.5 d5fee489f5 M7: recent files notice files deleted while the home screen is open
The recent files' dates were only read again when the home screen came
back into view, so a file deleted or moved in the meantime kept its card
until then. They are now also read again every 2 seconds while in view,
and at once when the window gets the focus back; a file that is gone
shows "File not found" without leaving the home screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:07:53 -04:00
tylerjenningsw 82d84d86b1 docs: zoom preview latency boundary and measurements (M8)
Refs #295
2026-10-14 11:59:24 -04:00
tylerjenningsw 72390d7b51 fix(canvas): preview in the browser and judge previews by coverage (M8)
Review of the zoom previews found three problems:

- The browser worker never previewed: wasm builds timed every render
  as 0 ms, below the 12 ms threshold. Renders are now timed with
  web-time's monotonic clock on every target (already in the tree via
  the engine), and the inline worker is built everywhere so a test runs
  it with the production threshold: preview one frame, refine the next,
  a newer request skips the old refinement.
- A preview was refused whenever the texture on screen was refined at
  the same zoom, even when that texture showed another part of the page
  (back at 100% about another point after 800%: 1/64 of the view). It
  now has to show the whole view asked for, not just its zoom.
- The latencies read the frame's start time before the inline render
  and before the texture was set. They now run on the same clock from
  the request to the image handed to egui as the canvas texture.

Refs #295
2026-10-14 11:57:58 -04:00
Barzabel 698e249fbf M1: drop shadows under a background blur (#255)
A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.

When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
2026-10-14 18:45:15 +03:00
Marco Arnulfo 1aeb5abb74 M1: a click on the toolbar no longer reaches the canvas under it (#307) 2026-10-14 17:43:32 +02:00
AganzeFelicite f9b7aa03ca M0.9: answer control requests while the window is hidden
A minimised or covered main window draws no frames, and control requests
were only answered inside a frame, so every control-channel and attached
MCP call (save_file among them) timed out after 60 s while the app was out
of sight. eframe still calls the app's logic then whenever a repaint is
requested, which the control server already does for each request.

The desktop app's logic now answers, while the window is hidden, the
requests that need no frame: engine.execute, engine.commands,
document.inspect, node.get, window.list and ui.render. Screenshots, input
and the interface's own state still wait for the window, and their
timeout now says that the window has to be on screen.

Closes #269
2026-10-14 17:27:01 +02:00
tylerjenningsw 3405a07d33 Contributors: add tylerjenningsw's names 2026-10-14 11:24:31 -04:00
Barzabel 4f321fb68d M1: creating a variable collection no longer closes the app (#224)
Creating a collection (and adding a mode or a variable, double-clicking
a row, or Rename in its menu) asked egui to focus the new name's field in
the same frame, but that field is only drawn from the next frame on. For
one frame the AccessKit update named a focused node that wasn't in the
tree, and the platform adapter aborts on that, outside the app's frame
guard; Windows keeps AccessKit on through UI Automation far more often,
hence the silent close there. A rename now leaves a flag and its field
takes the focus in the frame it's drawn, through widgets::rename_field,
as the layer rows do.
2026-10-14 18:21:08 +03:00
tylerjenningsw d72e5e0516 docs: zoom previews in the roadmap, gaps and control protocol (M8)
Refs #295
2026-10-14 11:15:11 -04:00
Marco Arnulfo ed0e7a9b2d M1: leaving Design ends the pen path and goes back to Move (fixes #307) 2026-10-14 17:11:20 +02:00
tylerjenningsw 4c23cb71c0 perf(canvas): render coarse zoom previews before refinement (M8)
A new zoom can't reuse the retained canvas image, and the previous one,
re-projected, covers little of the view after a zoom out (1/64 of it
from 800% to 100%), so the page background showed until the full render
finished.

When renders from scratch have been slow (a frame or more), the worker
first renders the whole requested view with a quarter of the pixels
each way, in a Retained of its own so pans and edits still build on the
full image, then refines it unless a newer request is already waiting
(that one gets its own preview). The web build renders the preview and
the refined image in consecutive frames. Pixel preview gets none.

Finished images carry the file, page and whether they are coarse: the
canvas never shows an image of another file or page, and a late preview
never replaces a refined image already at the zoom asked for. ui.inspect
reports the canvas quality and the latency from request to the frame
showing the preview and the refined image.

Refs #295
2026-10-14 11:08:55 -04:00
midasmatterhorn f1f7da1f40 engine: refuse page.move for node ids that are not pages 2026-10-14 18:01:41 +03:00
tylerjenningsw a3a273cfd3 fix(pages): inherit the current page background when adding pages (M1)
page.add gave every added page the interface theme's colour. Figma's
documentation says added pages inherit the current page's background;
the theme only decides a new file's page. Read the active page's colour
before creating the new page, in the command, so the app, CLI, control
channel and MCP agree.

Refs #295
2026-10-14 10:58:17 -04:00
fede9234 3917c26802 Text: colour-glyph comments say only what the fonts are documented to carry
"Placeholder rectangles" was a claim about Apple Color Emoji's glyf
outlines that no source I can show supports: the font's sbix chapter
documents that most emoji are bitmaps while the flags are drawn from
outlines, and it is Noto Color Emoji that has no outlines at all. The
comments, the ROADMAP row and the parity row now say that instead.

No behaviour change: comments and docs only.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 16:55:07 +02:00
AganzeFelicite 00ac44f6ca M1: save files automatically
Preferences > Save files automatically (pref.autoSave, off by default;
hidden in the browser, which has no files to save to). With it on, each
open file that has a path is saved 3 s after it changes, never during a
gesture, a live text preview or while a version is shown. A failed save
says why in a toast and tries again a minute later.

Version history checkpoints (version.autosave, every 30 minutes) only ran
for files with unsaved changes, so a file that saves itself would stop
getting them; with auto-save on, edits made since the file was opened
count.

Closes #296
2026-10-14 16:48:56 +02:00
fede9234 de5ba60bc4 Text: colour-glyph comments cite the right source and size
The placement formula is Skia's own fontations port
(src/ports/fontations/src/bitmap.rs) reading the sbix spec's
lower-left-corner rule; the earlier comment named CoreText, which that
source does not mention. Apple Color Emoji measures ~180 MB on current
macOS (the 190 MB figure is a hosted copy of the file), so the comments
say ~180 MB.

No behaviour change: comments only.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 16:44:20 +02:00
Barzabel 8b8825ce68 M1: say why when the window can't open (#261)
Release builds on Windows have no console, so an eframe::run_native error
(no usable graphics adapter, a device the driver refuses) went to an unseen
standard error and the app quit silently. The error is now logged and shown
in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise
update the driver or try another graphics API (OpenGL on Windows), and
where to report it. With --control the box is skipped so agents aren't
blocked.
2026-10-14 17:37:59 +03:00
fede9234 54ba84eddc Text: colour emoji draw from their own colour tables (#268)
Colour emoji came out as the placeholder rectangles their outlines carry
(Apple Color Emoji) or as .notdef when the face has no outline at all:
nothing read sbix, CBDT/EBDT or COLR. A glyph now asks its face for
colour ink before its outline: an embedded bitmap strike decoded to
premultiplied RGBA and placed by the strike's bearings the way Skia (and
CoreText) places it, scaled to the size the run asks for, or a COLR v0
glyph's layers filled from the font's palette.

Faces that carry only colour tables are indexed again: Apple Color Emoji
is no longer skipped for being 190 MB (the scan still reads headers and
name tables only), and "Segoe UI Emoji" and "Noto Color Emoji" join
"Apple Color Emoji" as colour fallbacks, read only for a text that
nothing else covers. The png crate decodes the strikes; skrifa keeps
shaping, metrics and outlines.

Left for later: COLR v1 paint graphs, alpha-only strikes and the SVG and
PDF exports (text is outlines there).

Tests: 8 cases in the text crate (placement, PNG and raw-BGRA decoding,
scaling, COLR layers and bitmaps of the machine's own colour font) and a
render case gated on that font; the font-free ones run everywhere.
Verified on Windows with Segoe UI Emoji (COLR) and with Noto Color Emoji
(CBDT) read from a downloaded file; the sbix path runs through the same
code and needs a macOS run to be seen.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 16:19:46 +02:00
MarekSpodBiedry a8a689d682 fix(server): keep each team library version in its own file (#206) 2026-10-14 16:19:01 +02:00
MarekSpodBiedry 6eeac4fa04 Keep the redo steps when a failed command batch rolls back (#196) 2026-10-14 16:14:35 +02:00
MarekSpodBiedry 8b961b980a Recovery: wait for the write in flight before an emergency save and before removing copies (#202) 2026-10-14 16:12:40 +02:00
NetterSilverandClaude Haiku 5.5 4193a82dfb Count a recovery revision as copied only after its write succeeds
Fixes #186

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:08:47 +02:00
Brandon ThomasandClaude Opus 5.5 833f0d2c27 Release 0.6.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:05:59 -04:00
fede9234 3709e7320c fix(desktop): Windows' first preferred language picks the UI language (#291)
Windows' preferred-language list is also the input-language list: a language
added for a keyboard sits in it, and taking the first entry with a catalog
turned the interface German on a Polish system (a German keyboard) while the
system and the other apps stayed Polish. Only the first preferred language now
decides; when it has no catalog the interface reads English. macOS'
AppleLanguages and gettext's `LANGUAGE` still take the first language with a
catalog. Fixes #291.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 14:54:01 +02:00
Marco Arnulfo e92792e3e8 M1: a press on a corner handle resizes instead of picking the corner-radius handle (fixes #292)
The corner-radius handle sits 10 px in from each corner and answers within 6 px, so a press on the inner half of the 8 px corner square picked it: the drag rounded the corners or, dragged outward, did nothing. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the constrain-proportions link); further in the radius handle still answers.
2026-10-14 14:36:39 +02:00
Marco Arnulfo 310cb40af9 M1: open .figcraft files from Finder, Open With and the Dock on macOS (fixes #289) 2026-10-14 13:58:37 +02:00
haranishi d1ac66a803 M9: 日本語のテンプレートと編集操作ラベルを補完 2026-10-14 20:43:30 +09:00
notdezzi 661144021e fix(format): exclusive unique staging files for atomic save/export
Saving and exporting wrote a truncating std::fs::write to a deterministic
staging name (<path>.tmp-figcraft, <file>.figcraft.tmp) before renaming, so an
unrelated file or a planted symlink at that name was clobbered or written
through, and separate writers of one destination shared a staging file they
could corrupt. Both paths now go through figcraft_format::write_file_atomic: a
uniquely named sibling staging file is created exclusively (create_new) and
written through its own handle, renamed over the destination, and removed again
on any failure, so only the staging file a write created is ever touched. Tests
cover pre-existing staging files and symlinks, concurrent writers, and
injected rename and permission failures.
2026-10-14 13:11:28 +02:00
RealtaNuaandClaude Opus 5.5 73b2845ba4 fix(engine): Alt-drag copies snap to their original; Duplicate repeats the step
- A copy Alt-dragged off a layer snaps to that layer too: its edges,
  centres and equal spacing (Targets::add_box with the originals' box).
  Before, the originals were left out of the snap targets, so a copy
  couldn't line up with or space evenly from the layer it came from.
- Duplicate (Cmd+D) repeats the last duplicate's page offset while its
  copies are what it duplicates: Alt-drag a copy, or Duplicate and move
  the copy, then each Cmd+D steps out the next one as far again. The
  step is measured when Duplicate runs. It lasts as long as the
  selection (undo reuses ids), copies placed by an auto layout set no
  step, and explicit dx/dy win.
- measure_tests: an Alt-dragged copy between its original and another
  layer now snaps to equal spacing (45/45 instead of 50/40).

Tests: duplicate_spacing_tests (snap to the original, equal spacing,
repeat after moving a copy and after an Alt-drag, only for the last
copies, undo then a new layer, rotated frame, auto layout).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:03:48 +07:00
notdezzi 10dc38495f fix(engine): roll back failed commands during interactions too
A command that fails part-way (node.set applies geometry keys before
values like visible, which can fail validation) mutated the document
even when an interaction was under way, because the rollback in
Session::execute was gated on interaction.is_none(); drop the gate so
the command-entry snapshot is restored during interactions as well,
which is the interaction's last valid preview and leaves the gesture
itself intact.
2026-10-14 12:54:02 +02:00
notdezzi efdd5fc645 fix(format): import explicit variable modes on frames and instances
A collection a saved file references by its asset key (the form library
collections keep) instead of a guid resolved to nothing, so every
explicitVariableModes entry was dropped silently and bound layers rendered
in the collection's default mode (#267). The .fig import now resolves such
references against the file's own nodes (the collection node carries the
same key) for mode entries and the variables' collection link, and reports
modes of collections it can't resolve.
2026-10-14 12:40:17 +02:00
Justin John 61def74383 M0: finish canvas drags released outside 2026-10-14 16:04:34 +05:30
Marco Arnulfo ce366ea325 M6: .fig import: a resized nested instance keeps the non-geometric derived data; resize check with a tolerance (#266) 2026-10-14 12:14:45 +02:00
Marco Arnulfo d8556e8f17 M6: .fig import: size overrides keep the constraint placement, nested instances resized again drop their old derived layout (#266) 2026-10-14 12:12:46 +02:00
Marco Arnulfo 01eb35df25 M6: .fig import lays out the layers of resized instances by their constraints (fixes #266) 2026-10-14 12:00:02 +02:00
RealtaNuaandClaude Opus 5.5 b8e5b8e133 fix(pen): show the bezier handles while drawing
The Pen showed only its anchors while a path was drawn; handles appeared
only in vector edit mode afterwards. Now:

- The point being dragged shows its in and out handles, and the point
  before it its out handle (the handles shaping the segments at the pen),
  drawn like vector edit mode's (one shared canvas helper).
- Moving on to the next point, the rubber band is the segment a click
  would make: it leaves the last anchor along its out handle instead of
  going straight.
- A press no longer leaves the previous straight rubber band on screen
  over the segment being dragged.

Overlay gains pen_handles and rubber_handle (shown in ui.inspect).

Tests: pen_overlay_tests (handles during and after a drag, none and a
straight band for corner points, cleared on finish); canvas_fixes_tests
(handle lines and dots painted mid-drag, curved band after release).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:58:51 +07:00
RealtaNuaandClaude Opus 5.5 9c0b441119 fix(ui): disable alignment when the selection can't be aligned
One layer directly on the page has nothing to align to (one layer aligns
to its parent frame), so the six Alignment buttons looked active and did
nothing. They are now disabled there, with a hover explaining why.

- cmd/arrange.rs: align_target (unchanged rule) and pub can_align.
- right.rs: the align rows in add_enabled_ui + on_disabled_hover_text.
- The hover text in every catalog.

Tests: a_layer_alone_on_the_page_has_nothing_to_align_to (engine),
the_align_buttons_are_off_for_a_layer_alone_on_the_page (AccessKit).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:53:33 +07:00
RealtaNuaandClaude Opus 5.5 3f57a4b76a fix(ui): gradient type icons that show their gradient
The colour picker's paint type row borrowed unrelated icons for the
gradient kinds (auto layout's bars, a circle, the rotate arrow, the
instance diamond). Each kind now has its own icon: the Solid swatch as
an outline with that kind's fade inside (linear ramp, radial, a conic
sweep from the top, diamond), drawn with a new per-vertex-alpha mesh
helper.

Test: gradient_icons_fade (each paints a mesh from opaque to clear).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:52:00 +07:00
Marco Arnulfo bd482ea6dd fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) 2026-10-14 11:15:32 +02:00
Marco Arnulfo 64a54ca408 M7: double-clicking a page in the Pages list no longer closes the app (fixes #264) 2026-10-14 11:11:00 +02:00
Yaman DevrimandClaude Opus 5.5 5f3e1e1e6c M9: Turkish catalog reviewed by a native speaker
Drops the draft status from tr.toml (the Preferences window no longer
calls it a draft) and marks Turkish reviewed in ROADMAP.md,
docs/localization-parity.md and docs/translating.md. Turkish stays out of
COMPLETE, so new strings don't have to be translated into it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:47:57 +03:00
ahmed-226 dde193cbf4 M7: a slice's export preview shows the page content under it
A slice paints nothing itself, so thumbnail() rendered an empty pixmap of the slice's own box: expanding Preview for a slice showed only the transparency checkerboard while exporting the same slice wrote the artwork correctly.

Give the preview the export's slice rule (drawn_layers + export_area in cmd/file.rs): draw every top-level layer of the slice's page over its world bounds. Regression tests cover a slice over artwork, a slice with nothing under it, and a plain layer.

Signed-off-by: Ahmed Mahmoud <[email protected]>
2026-10-14 10:37:23 +03:00
VanTan 4964910cc9 M7: follow system theme from the first frame 2026-10-14 10:34:14 +03:00
Yaman DevrimandClaude Opus 5.5 06850b8900 M9: Turkish draft catalog
crates/ui-egui/locales/tr.toml translates every UI string (1,577 keys),
command label (557) and menu wording (31), machine-drafted and marked
"draft — needs native review". Buttons and menu items use bare
imperatives (Kaydet, Sil), instructions the polite -in/-ın (seçin,
tıklayın); the percent sign goes before the number (%50). Turkish keeps
a noun singular after a count, so plural entries give only `other`.

Preferences ▸ Language lists Türkçe; System maps tr and tr-TR to it.
The catalog passes the i18n check even when listed as complete.
docs/translating.md, docs/localization-parity.md and ROADMAP.md list
the new draft. Adds my own contributors/people.toml entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:34:10 +03:00
VanTan 7a0bd980c6 M9: add Russian interface translation 2026-10-14 09:33:58 +03:00
Piettro CastroandClaude Opus 5.5 f3cd0509cb fix(engine): read the documented gradient shorthand on every route (#252)
`{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP
tools, so `figcraft-cli run` and the control channel's `engine.execute`
refused it ("`fills` must be paints or colours"). The expansion now lives in
the engine (`params::normalize_paint`, used by `paint_of`), so node.set,
node.create props, fill.add/set, strokes and paint styles all take it; MCP
calls the engine's copy instead of its own.

Tests: engine (every paint command, MCP-equal defaults, junk still refused),
control channel `engine.execute`, and `figcraft-cli run` end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:02:45 -05:00
Justin John 94ce2a3611 M13: make native file dialogs asynchronous 2026-10-14 11:04:30 +05:30
bcedfeba10 M7: recent files as thumbnail cards on the home screen (#249)
The Recents section shows recent files as cards: the picture each file
carries (thumbnail.png in .figcraft and .fig, an image file itself) fitted
on its first page's colour, a divider, and a footer with the file's icon,
name and "Edited ... ago" (or "File not found"); the full path on hover.
Pictures are read off the frame's thread, only for cards in view, cached
by path and modification time, capped in bytes and pixels; missing,
damaged or oversized ones give a placeholder. They fade in, and cards
show a skeleton until their file has been looked at.

A sort menu (Alphabetical, Date created, Last modified; either order) and
a grid/list toggle sit above them. The list is a Name, Last modified and
Created table whose headers sort. View (per section) and sort are kept in
the preferences and set with ui.set {homeRecentsView, homeRecentsSort,
homeRecentsOrder}. A click selects, a double-click or Enter opens, arrow
keys move the selection; a right-click offers Open, Show in folder and
Remove from recents (file.openRecent {remove}).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bflatastic <[email protected]>
2026-10-14 12:00:20 +07:00
Brandon ThomasandClaude Opus 5.5 b34c5de2ab CI: run FreeBSD and Windows ARM64 checks nightly and on release, not per PR (#121)
They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock,
which is nearly every PR. See craftrules release/playbook.md §2.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 11:53:56 +07:00
BFlat 52d013e1db Docs: roadmap entries for the community fixes in #221, #226, #228, #229, #231 to #238 and #240 to #242 (#246) 2026-10-14 11:35:04 +07:00
rcoenenandClaude Opus 5.5 6267ae5638 Vector edit mode: hover, hatch, select and drag filled regions (#243)
With the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour (1.4 px), clipped by the area's own fill rule so holes stay clear. The hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); between those clearances and the 6 px hit radius the area wins. Bezier handles, the transform box's handles and other edited layers' parts come first. Line spacing follows octave folding: 8 px at every power-of-two zoom, growing with the zoom and stepping back at sqrt(2) * 2^n. The pattern is anchored to the vector's origin, so it stays on the shape when panning, and several selected areas share one pattern. The last area listed wins where areas overlap, and only vectors with a visible fill have areas.

A click selects an area with its points; they move with it (drags, nudges, the Design panel) but keep the unselected look and show no handles. Shift adds an area or takes a selected one out (its own points go, points selected on their own or shared with another selected area stay). A press on a selected area drags everything selected, as one undo step; Escape puts it back. Drags show no hover hatch. Any other press without Shift drops the areas and the points selected only through them, and a click on an empty spot such as a hole clears them and stays in edit mode. Inside the transform box around points selected on their own, the box keeps its drag unless the area is already selected. Area indexes are dropped when the network's topology changes; moving points keeps them.

The network gets region walking, an inside test per region by its own fill rule, the region under a point, a region's points and a topology fingerprint (vector_regions.rs). The engine does hit testing and lays out and clips the hatch in screen space (vectoredit/regions.rs); the canvas only draws the lines. vector.edit.select takes regions, and vector.edit.state reports regions, regionVertices and regionHover. Tests: vector_regions_tests (doc) and regions_tests (engine), covering fill rules, overlap, curves, reversed segments, clearances at 100% and 400%, selection, drags, undo and cancel, and the hatch spacing at seven zoom levels, its direction, anchoring, coverage and clipping.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 11:17:17 +07:00
5e90592e80 M1: report images a native file lost on loading (#207) (#242)
A `.figcraft` image member that is damaged, too large or missing was skipped
without a word, and saving the file again dropped it for good. The load now
lists each lost image (up to 20, then a count) with its hash and the reason,
file.open puts it in `repairs` and sets a session message, and the app shows
that as a toast.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: bflatastic <[email protected]>
2026-10-14 10:32:44 +07:00
ef2c6cb30e M1: SVG import budgets for SVGZ and embedded SVGs (#190) (#241)
usvg unpacks SVGZ with no limit and parses SVGs embedded in `<image>` data
URIs with no shared depth or byte budget, and the nesting check ran on the
compressed bytes. FigCraft now unpacks SVGZ itself within 64 MiB, checks the
real document, and resolves embedded SVGs with its own data resolver: one
32 MiB budget of unpacked bytes and at most 4 levels, an embedded document
past a budget is left out. Rasters still resolve; linked files stay unread.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: bflatastic <[email protected]>
2026-10-14 10:32:30 +07:00
24c5617230 feat(dev-mode): Leptos code with component properties, instance calls and Code Connect (#240)
* feat(dev-mode): Leptos code for a layer tree (#223)

inspect.code takes language: leptos, and the Inspect panel lists it: the
HTML markup inside a #[component] fn's view!, with the stylesheet after it
as for React. Text is Rust string literals (line breaks as <br/>) so the
macro parses it; px or rem like the other web languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(dev-mode): Leptos components take their properties, instances are calls (#223)

Leptos joins React in component code: a component or set is a #[component]
fn with #[prop] defaults (variant enums with as_str, bool, String, ViewFn
for instance swaps, Option<ViewFn> for slots), layers bound to booleans
show through .then, variants differ by data-* CSS rules, and instances
(also inside layer trees) are calls passing the values they change. Names
are snake_case, Rust keywords get a trailing _. Code Connect takes leptos
too (use <path>::<Name>; view! markup). Markup now takes the language
instead of jsx/leptos flags; React and Leptos share the stylesheet pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bflatastic <[email protected]>
2026-10-14 10:32:14 +07:00
fb8ace5bf4 M1: full screen (#227) (#238)
View > Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux,
Ctrl+Cmd+F on macOS) makes the window fill the screen, taskbar included,
and leaves it again. FigCraft's own menu item, translated in every catalog.

Tests: the key and the command toggle the window, junk parameters are
ignored; the shortcut table pins the new binding (49 pinned).

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: bflatastic <[email protected]>
2026-10-14 10:31:59 +07:00
ce84d1f27f fix(ui): keep wrapped colour picker swatches inside the picker (#180) (#237)
add_space(16) only indented the first row of a horizontal_wrapped, so with
many colours on a page the later rows started at the picker's edge and ran
past its right side. The swatch rows now sit in a frame inset 16 points on
both sides.

Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 10:31:46 +07:00
8e12e5eeda fix(server): never persist a snapshot the reader refuses (#188) (#236)
Snapshots hold more than the document (recent batches, deletions), so near
the cap compaction could write one over max_file_bytes, which both readers
then refused: the file vanished on restart. Reading now allows twice the
cap, and compaction refuses a snapshot beyond that, keeping the old
snapshot and log.

Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 10:31:30 +07:00
118ca7c60b fix(desktop): write preferences owner-only on Unix (#200) (#235)
state.json holds online owner tokens. The temporary file is now created
0600 (and narrowed if a crash left one behind) before anything is written,
and the rename carries that mode to state.json, so an older
world-readable file is replaced on the next save.

Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 10:31:17 +07:00
d61ccc633a fix(doc): reject and repair layer cycles and repeated children (#205) (#233)
validate() now reports children listed twice, pages with a parent, and
nodes not reachable from a page (cycles, detached subtrees). repair()
drops repeated children, clears page parents and removes every node not
reachable from a page in one linear pass, replacing the orphan loop.

Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 10:31:03 +07:00
badcb6ec21 fix(deps): require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#182) (#234)
Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 10:30:46 +07:00
BFlat 7a9a67a8c3 Docs: roadmap entries for the community fixes in #211, #213, #214, #216 and #218 (#230)
* Docs: roadmap entries for the community fixes landed in #211, #213, #214, #216 and #218

Their ROADMAP.md lines were left out when the five PRs were rebased (all five edited the
same header line); the wording is the contributors' own.

* Docs: the header lists all five community fixes
2026-10-14 09:30:55 +07:00
MuhammadyusufandClaude Opus 5.5 dd3d4d326c feat(packaging): run figcraft-cli from the AppImage with cli <args> (#232)
The AppImage ships figcraft-cli in usr/bin, but AppRun only ever launched the
app, so the CLI (and its MCP server) could not be reached without extracting
the image. `FigCraft.AppImage cli mcp` now execs `figcraft-cli mcp`, skipping
desktop integration because the CLI is headless.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 09:30:42 +07:00
6d735653a2 fix(render): keep one outline depth budget through nested booleans (#193) (#231)
Boolean operands restarted subtree_outline at depth 0, so booleans nested
in booleans bypassed the 64-level limit and could overflow the stack.

Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 09:30:29 +07:00
Giuseppe Cautieroandbflatastic ac65168417 Implement centralized transactional fix for command batch failures (#229)
* Agent Host changes for agents/fix-command-batch-history-restore

* Implement centralized transactional fix for command batch failures - #1

* The batch rollback tests compile, are formatted and expect the tab that was in front

The new module used a private helper of plugins_tests and an unused import; the plugin test
asserted tab 0 was in front, but it opens a second file first, which is the one in front
when the plugin starts and the one the rollback puts back.

---------

Co-authored-by: bflatastic <[email protected]>
2026-10-14 09:30:16 +07:00
Jeremy YaoandClaude Opus 5.5 f7e726714a fix(text): don't rebuild every time a craft-fonts manifest is missing (#228)
crates/text/build.rs asked cargo to watch both craft-fonts manifests
whether or not they existed. Cargo takes a watched path that doesn't
exist as changed, so figcraft-text and every crate above it were rebuilt
on every build: with no craft-fonts checkout, and also with a checkout
of craft-fonts' main branch, which has no fonts/latin-manifest.txt.

Watch a manifest only when it exists, and its folder while it is
missing, so a manifest added later is still noticed. With no checkout
at all there is nothing to watch; the build warning and the README now
say how to pick one up after a first build.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 09:30:03 +07:00
Jeremy YaoandClaude Opus 5.5 7d589c4456 fix(desktop): use Direct3D 12 on Windows instead of Vulkan (#226)
On Windows, eframe offered wgpu every backend and wgpu ranked Vulkan
first. On a laptop with two AMD GPUs whose drivers are years apart, the
Vulkan driver faults (0xc0000005 inside amdvlk64.dll) right after the
first frame, so the app died as its window opened. An access violation
in a driver thread is not something the app can catch.

Offer wgpu Direct3D 12 on Windows, with OpenGL behind it for hardware
without a Direct3D 12 driver. WGPU_BACKEND still names the backend on
every system, and other systems keep eframe's defaults.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 09:29:21 +07:00
Sir Clangington III. 79a0e58a3c fix(plugin): meter every host call and copied byte (#221)
Every plugin import now counts as a host call (reply_len, reply_read and set_output were free), and bytes copied out of or into the plugin's memory spend fuel, reserved before the copy or allocation, including when call overhead is set to zero. Fixes #192.

The log byte budget test from #216 runs with the default fuel, since copying a megabyte of log bytes is itself charged now.
2026-10-14 07:42:06 +07:00
fede9234 db55720c93 fix(engine): keep proportions when a linked width or height is typed (#222)
With a layer's proportions locked, setting only its width or only its height through node.set (the W and H fields) scales the other side by the layer's own ratio; setting both in one edit leaves them as typed, an unlinked layer is unchanged, and a zero side has no ratio to keep. Fixes #220.
2026-10-14 07:31:19 +07:00
YTwsy 0efbd55564 Bound plugin logs before allocation and host forwarding (#216)
Plugin log lines are admitted before guest memory is copied: a line over 8 KiB, more than 1,000 lines or more than 1 MiB of log bytes in a run fails the run with a limit error, and the same budgets bound what is forwarded to the host logger. Fixes #191.
2026-10-14 05:49:32 +07:00
Euisuh Jeong 5b52eeeb36 fix(collab): reject reverting an active live document (#214)
file.revert is refused while the file is in a live session (hosting, joined, publishing or reconnecting): reverting replaced the document's identity and broke its session. The refusal changes nothing, and reverting works again after leaving. Fixes #198.
2026-10-14 05:47:39 +07:00
Euisuh Jeongandbflatastic 8b7435890e fix(mcp): prevent replay after lost attached replies (#211)
The attached MCP backend no longer re-sends a request when its reply is lost: the app may already have applied it, so the error says the operation may have completed and to inspect the document before retrying; the next call reconnects, and a failed reconnect says the request was not sent. Fixes #194.

Also: a connection the app already closed (it drops ones idle past its socket timeout) is noticed before the next request is written and replaced then, so a pause between calls is not an error.

Co-authored-by: bflatastic <[email protected]>
2026-10-14 05:34:33 +07:00
YTwsyandbflatastic 33dcb1aef6 Refuse incomplete SVG/PDF exports beyond the depth limit (#218)
SVG and PDF exports check the visible subtree's depth before bounds or drawing and refuse, with an actionable message, a document nested past 256 levels instead of silently leaving the deep layers out; nothing partial is written, and an inner layer can still be exported. The CLI and MCP report the same error. Fixes #208.

Also: on Windows, figcraft and figcraft-cli link with an 8 MB main-thread stack (Windows gives 1 MB where Linux and macOS give 8), so an export at the depth limit no longer overflows it.

Co-authored-by: bflatastic <[email protected]>
2026-10-14 05:23:28 +07:00
Euisuh Jeong e8d7dc24f2 fix(control): bound frames and expire queued requests (#213)
MCP and control-channel lines are bounded while they are read (32 MiB; an oversized frame gets an error and the stream closes without being drained). The loopback control listener caps connections at 8, applies 60 s socket read and write timeouts, caps both request queues at 8 and discards requests whose client stopped waiting. Fixes #201.
2026-10-14 05:18:40 +07:00
Euisuh Jeong d30255bf62 fix(engine): preserve the underlying repeat command (#212)
edit.repeat no longer records itself as the last command, so repeating keeps applying the command it repeats instead of recursing, and it refuses when there is nothing to repeat. Fixes #181.
2026-10-14 03:26:16 +07:00
Saad Abdullah 01904ce2fd fix(geom): enforce the path sample limit while flattening (#217)
The sample budget is enforced while a path is flattened, not after: the element count is checked up front, each curve is flattened on its own, and flattening stops as soon as the budget is exceeded. Fixes #204.
2026-10-14 03:26:11 +07:00
bflatastic e56856f401 M3: component playground: variant, boolean and text properties 2026-10-13 13:36:54 +07:00
bflatastic e02ad435b3 M2: auto layout: dragging an automatic gap's handle fixes the gap 2026-10-13 13:36:54 +07:00
bflatastic a399ff8934 M3: variables inside styles 2026-10-13 13:36:54 +07:00
bflatastic d7f76246d3 M3: effects on groups: inner shadows and background blur 2026-10-13 13:36:54 +07:00
bflatastic fca70a6063 M1: text made by commands in the app is readable on dark pages 2026-10-13 13:36:54 +07:00
bflatastic 2c35c7bd21 M1: frames and groups: one-step convert 2026-10-13 13:36:54 +07:00
bflatastic 5548284c65 M5: prototyping: delays on mouse triggers, manual overlay offsets 2026-10-13 13:36:54 +07:00
bflatastic a2a0f80252 M2: auto layout: wrapped-line alignment 2026-10-13 13:36:54 +07:00
BFlat 1667b7bc91 M9: sessions test waits for the rename it asserts on (#172)
The sessions test waited for a new layer to arrive and then asserted on its name, which the host sets in a second batch; it now waits for the name. Test only.
2026-10-13 10:05:56 +07:00
bflatastic ac6adc2d54 M7: imported nested instances link to their main's layers
A nested instance opened from a .fig or file JSON document is first linked to its
own component; matching it to the outer main's layer then went by that component's
name instead of the layer's, so nested instances of variants (or under a name of
their own) and their layers stayed unlinked, and a second export lost what they
changed. They go by their own name until they mirror a layer.
2026-10-13 10:05:39 +07:00
bflatastic c910060f36 M7: .fig export: one-sided stroke widths as outlines, bound layer fields name their field; docs
A variable width is written only as the same reach on both sides of the path (a
point with unequal sides drew nothing elsewhere). A stroke wider on one side, on a
shape that shows nothing else, is written as a vector filled with the area it
paints, with the stroke itself kept as plugin data that the import puts back.
Bound layer and text fields also name the number of the field they set. Docs: text,
effect and grid styles and symmetric variable widths are checked; non-colour
bindings come across as values.
2026-10-13 10:05:39 +07:00
bflatastic 3efff16df2 M7: .fig export: stroke reach as a share of the weight; docs
Variable-width points say each side's reach as a share of the stroke weight (in
pixels they drew far too wide elsewhere). Progress docs: instance sizes, variables
with modes and shared styles in the .fig export, and what was checked by opening
written files in other software.
2026-10-13 10:05:39 +07:00
bflatastic 03b173fd98 M7: .fig export: instance sizes, variables and modes, shared styles, variable-width strokes
Instances write what they change from their main as an override addressed by the
main's id (size, name, paints), what follows from that for their layers as derived
data (size, place, outline, colours their variables give), and a scale factor when
they were scaled as a whole; layers of a nested instance swapped for another
component keep their overrides. Variable collections, modes and variables are nodes
on a hidden page; paints, gradient stops, effects, layout grids and layer fields
name the variables bound to them, and frames and pages the modes they set. Shared
styles are nodes there too, linked from the layers that use them. Variable-width
strokes say their reach on each side of the path. The import reads all of it back
(style definitions and unused styles, variable order and scopes, bindings on paint
lists, stops, effects and grids, page modes, text bindings, width profiles).
2026-10-13 10:05:39 +07:00
bflatastic 794b83a0db Sharing: the default server is a build-time setting
FIGCRAFT_DEFAULT_SERVER, set when compiling, names another server or, set to nothing, ships a
build with no default: online sharing then stays off until the user enters a server in
Preferences. Normal builds and the release workflow are unchanged.
2026-10-13 03:01:34 +07:00
bflatastic 511213e820 Docs: terms and privacy for online sharing through the default server 2026-10-13 03:01:34 +07:00
bflatastic 9c9ccecbc5 M11: starter templates: parity checklist regenerated after the rebase 2026-10-13 03:01:19 +07:00
bflatastic 5be3012e9b M11: starter templates: docs and the essentials assessment 2026-10-13 03:01:19 +07:00
bflatastic 573dd521bb M11: starter templates: Templates tab, first-run templates and Getting started card on the home screen 2026-10-13 03:01:19 +07:00
bflatastic c2a67f81e5 M11: starter templates: eight templates built in code, template.list and file.newFromTemplate, help.gettingStarted 2026-10-13 03:01:19 +07:00
bflatastic 31815f1d13 M9: online files: crash recovery never drops an unsent edit
The recovery copy of one of your online files is no longer discarded when
the file reopens from its server. It is kept until the outcome is known:

- the server has everything in it: the copy goes;
- the copy has changes that never reached the server and nothing else
  changed there: they are applied on top as one edit and sent to everyone;
- anything else (others changed the file, the server forgot the client):
  the copy opens beside the file as "<name> (recovered copy)", with a toast;
- the server can't be reached: the copy opens as an unsaved document.

The note beside a copy is written after it and records who the app was in
the file and the server's last change it had. The app joins again as that
client (the first hello resumes; a new sync client numbers its batches
after the ones the server processed), so the layers it made are its own to
send. The old copy is removed only once this run has copied what it became.
collab.recoverFile; a tooltip on the tab mark; docs.
2026-10-13 01:41:14 +07:00
bflatastic 501e4a6737 M9: online files: the app remembers the files it put online
A file put on a server without the server's key stays its owner's after its
tab closes or the app quits. The app records it with the preferences (server,
id, name, owner token, the owner's links, what others join with; at most 200;
validated when read back) and never shows the token: not in command results,
ui.inspect, logs or debug output.

- Home > Online lists your online files: open one as its owner (a new tab, or
  its tab comes to the front), start and stop sharing, copy the code or link,
  rename on the server, remove from the server (confirmed), forget here. One
  request per server when the section opens; offline and gone states.
- collab.files, collab.openFile, collab.forgetFile; collab.manageFile takes
  its server and token from the record and gains share / unshare.
- Server: Admin owned (a compatible addition within protocol version 2) says
  what became of the files a list of owner tokens owns. Older servers leave
  the list as recorded.
- Tabs mark files in a live session (shared, or open only to you); toasts
  about a file that isn't in front name it; after a crash a recorded file
  reopens from its server as its owner.
- Draft catalogs (de, fr, ja, ko, pt-BR, zh-Hans) translate the 71 strings
  and 5 command labels they lacked and the new ones; es and es-419 follow.
- Docs: collab-protocol, server, mcp, control-protocol, parity pages, gaps,
  ROADMAP.
2026-10-13 01:41:14 +07:00
BFlat 517b1a02ce Docs: privacy and online sharing (#167)
The README gains a Privacy and online sharing section (what Share > Online uploads and where, how long it stays, encrypted in transit but not end-to-end, codes and links are the access, local network sharing is direct and unencrypted, no guarantees), its 'Offline and private' blurb no longer says 'no cloud', and docs/server.md's Security section says the same for operators.
2026-10-13 00:49:06 +07:00
bflatastic 39e9e87b74 M1: number fields: scrub rates, arrow steps, arithmetic, relative edits of mixed values
- Scrubbing the label: a step a point, x10 with Shift, x0.1 with Alt, each
  stretch at its own rate, counted from the press; on past the screen edge
  where the platform reports the mouse's motion.
- Arrow keys: pixel fields by the nudge preferences (Shift: the big nudge),
  other fields by their own step and ten of it; also with the keyboard on
  the field; the stepped value stays selected.
- Typing: brackets and units in arithmetic; +10, *2, /4 and (outside
  percentage fields) 50% are relative to the value.
- A field left as it was commits nothing (no undo step, no rounded display
  written back); Tab commits and moves on; Escape restores.
- Mixed values: a relative edit or an arrow key goes to each layer from its
  own value in one undo step (X, Y, W, H, rotation, opacity, corner radius,
  stroke weight).
- Fields take keys in a window that isn't in front (control channel).
- Docs for the everyday editing partials: ui-parity rows, ROADMAP,
  target-app-parity, gaps, mcp, control protocol, the parity checklist
  (211/211).
2026-10-13 00:29:41 +07:00
bflatastic ce094f397a M1: Hide when publishing from the Assets panel
component.hideWhenPublishing {ids?, on?} keeps this file's components and
sets out of the library it publishes. The model's sign of it is the name,
so the command writes it: hiding puts a "." before the name, showing takes
the leading "." and "_" away (one undo step, saved and synced like a
rename). The Assets panel's context menu on a local component toggles it
(checked while hidden) and the tile shows a crossed eye; also under
Libraries in the main menu.
2026-10-13 00:29:41 +07:00
bflatastic 485715f11e M1: cursors per tool and modifier, panel widths, outline options, dark pages
- Cursors: the engine names a cursor for every tool, modifier and hover
  target (rotate turned to its corner, pen states, eyedropper, comment,
  copy on Alt, not-allowed while an instance refuses the edit); the UI shows
  the system's or draws its own; guides and handles keep their cursors;
  ui.inspect reports it.
- Panels: each edge drags its panel within limits, a double-click resets,
  widths are per window and kept in the preferences (view.panelWidths); a
  small window keeps its canvas.
- Outline mode: Include hidden layers and Include object bounds
  (view.outlinesHiddenLayers, view.outlinesObjectBounds), drawn and
  clickable; light lines on a dark page; check marks in the View menu.
- A file or page made in the dark theme has a #1E1E1E page (sessions
  without an interface keep #F5F5F5); text drawn on a dark page is white.
- Tests for Enter and Escape on a selected text.
2026-10-13 00:29:41 +07:00
BFlat c5830e4683 Release: retry stapling and its validation on macOS (#164)
Apple's notarization ticket service dropped the connection in two release runs in a row
(once while validating the stapled DMG, once while stapling), failing the macOS job after a
successful build, signing and notarization. Stapling and its validation now try up to five
times, 15 s apart.
2026-10-12 21:41:02 +07:00
BFlat c7771c7bb7 Release prep: 0.5.1 (#163) 2026-10-12 20:24:55 +07:00
bflatastic 8f6a123f29 M9: share with a join code: stop sharing keeps the file
Online, the file is its owner's: others get links and codes of their own, and
Stop sharing (collab.stop) revokes those on the server. Whoever joined with
them is disconnected at once and told sharing was stopped (the session's end,
with their copy); the owner stays in the file, which stays on the server, and
Start sharing gives the same file new links and codes. Removing the file is a
separate, confirmed step under Advanced.

The join field is the shared text input: as tall as the Join button beside it.
2026-10-12 19:42:28 +07:00
bflatastic 6720ea4a21 M9: share with a join code: online through the built-in server or on the local network
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.

Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. Older
servers leave the links in place of a code; older apps join by link.

collab.share, collab.join {code}, code and share in collab.peers; the server
preference defaults to the built-in server and can be reset or turned off.
2026-10-12 19:42:28 +07:00
bflatastic f77d9a467e M3: text metrics: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only
Measured against documents re-saved by other software. A line's height is rounded to whole
pixels (halves up) with the baseline keeping its fraction inside it; a glyph's advance and its
kerning are each rounded to 1/1024 em before scaling; a line's width and wrapping leave out the
letter spacing after its last visible glyph. Rule tests on built-in fonts, a local-only check over
such documents, and three behaviour expectations updated to the new rules.
2026-10-12 17:54:18 +07:00
bflatastic 94349d86b0 M9: live sessions per document
Live sessions are per document instead of one for the whole app.

- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
  installed link, the invite-link registration) plus one `Live` per session. One place resolves
  "the session of this document": `Session::live()` (the active document's; status off when it
  has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
  spotlight and presence all go through the session of the document they are about, so edits and
  presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
  `(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
  link keep them in a shared table with one mailbox per session
  (`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
  `collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
  one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
  peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
  touching other sessions; until then (or if it fails) it shows on the tab it was started from.
  Starting a session for a document replaces only that document's own session. Closing a document
  ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
  banner, follow frame and read-only reason read the session of the tab in front, per window. What
  belongs to one session in the window (cursors shown, your chat box, a report being written) is
  dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
  `sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
  `docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
2026-10-12 17:54:18 +07:00
bflatastic 0e6dc6871b M7: .fig export that opens and draws in other software
- The document and its pages now say they are visible and opaque, and a page carries its background opacity and enabled flag. Left out, a reader took them as hidden and drew nothing: every frame was blank and the page background transparent.
- The schema written into the file uses the format's own names, kinds (struct or message), field ids and enum values for everything the export writes. The schema text parser takes `NAME=n` and `type field=n`.
- Rectangles are written as rounded rectangles with their four radii. Frames, components, instances and sections carry their fill outline; plain strokes carry their stroke outline (a ring around boxes and whole ellipses, the widened path of other centred strokes).
- Text carries its layout settings and one line entry per paragraph. Font styles are written under their standard names ("SemiBold" becomes "Semi Bold", "Regular Italic" becomes "Italic"); the import reads both spellings as the same style.
- A line height that is a share of the font size is written as a raw multiplier: percent units count from the font's own line height, so 140 % was laid out taller than in FigCraft.
- Masks keep their type (alpha, outline, luminance), written and read back.
- The import reads per-side stroke weights whose zero sides are left out (a bottom-only border no longer comes back on all four sides).
- `meta.json` gives the page colour and the thumbnail size; archive members are in the usual order. Texture effects are written under the format's grain type; WebP export settings are written as PNG with a warning (the format has none).
- Docs: `docs/file-format-parity.md`, `docs/gaps.md` (gap 1), `ROADMAP.md`, `docs/target-app-parity.md` (wording only; no number moves).
2026-10-12 17:54:18 +07:00
bflatastic 2cf4510cbd M1: a double-click that enters a text selects all of it
A double-click in the text already being edited still selects the word.
2026-10-12 17:54:18 +07:00
BFlat 47fff74655 M1: double-clicks into text in groups, and a drag threshold (#160)
A move or a resize starts only once the pointer has travelled 3 px on screen, and a double-click drops the gesture its second press began, so double-clicking text inside a group reliably selects it and then edits it instead of sometimes dragging or resizing it.
2026-10-12 16:52:37 +07:00
Brandon ThomasandClaude Opus 5.5 1610d3db86 Release prep: 0.5.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:44:27 -04:00
BFlat 76929f63ce App icon: full rounded tile on Windows and Linux (#159)
The hicolor theme, the .ico and the window icon now show the whole tile with its rounded
corners, like the other Crafting Apps, instead of a crop that cut most of the corners off.
2026-10-12 15:26:54 +07:00
BFlat 8df9998044 Docs: retake the README screenshots with the new app mark (#158)
The seven docs/images/shot-*.png are captured again from a build with the curled-tail mark: same files, 1440x900, same example documents, views and selections, so README.md is unchanged. The art-*.png renderer exports have no UI and stay as they are.
2026-10-12 15:08:23 +07:00
BFlat 16fe57e94f App mark: the chameleon's curled tail (#157)
The mark in the left rail, file tabs, home screen and About is now a curled tail drawn in
code as one bold stroke (a shaft and three tightening half turns), taken from the chameleon
on the app icon. It replaces the three stacked bars.
2026-10-12 14:49:14 +07:00
BFlat a0d1e72c8d App icon: the chameleon, vectorised like the other Crafting Apps (#156)
The owner's engraving-style chameleon drawing, traced to three colours (ink, paper,
FigCraft green #02da96) on the shared 512-unit tile. assets/app-icon/figcraft.svg is the
master; cargo xtask icon now renders the PNGs (macOS grid), the hicolor theme and the .ico
(tight crop, 16-256 px) from it with resvg instead of drawing the old geometric mark.
Off macOS the window icon uses the tight 256 px render.
2026-10-12 14:25:29 +07:00
bflatastic ab94cff913 Release prep: 0.4.0 2026-10-12 12:02:02 +07:00
bflatastic 9fde7fed3a M9: oversized-message test sends only the frame header (no broken pipe on Linux) 2026-10-12 11:40:03 +07:00
bflatastic 98c4753118 M9: open hosting: fair limits for shared addresses
Third review:
- a broadcast counts once per recipient block, welcomes don't count
  against block caps, and presence, cursor chat and pings have their own
  per-address budget, so an office behind one address works together
- when everyone's reading budget runs out, the biggest readers are cut
  off until it fits (their bytes freed at once), no cliff
- block counters are forgotten once only the table holds them
- install.sh: root's downloads, clone, swap file and built binary in a
  root-only directory; the build user builds its own copy; the binary is
  copied out with the build user's rights
- recommended open-mode settings in install.sh's config and the docs
2026-10-12 11:40:03 +07:00
bflatastic e0435d4eb4 M9: close the remaining open-hosting DoS paths
Second security review:
- a broadcast counts once against the queued-bytes budget; each block of
  addresses gets at most an eighth; when the budget runs out the biggest
  consumer is shed, not the receiver
- forced compactions at most once a minute per file
- plugin downloads check the per-address share before the global budget
  and before reading
- install.sh never replaces a Caddyfile with other sites (one import line,
  once; install-test.sh in packaging lint); the built binary runs only as
  the server's account
- bytes being read count against per-block and global budgets, the
  biggest readers dropped first
- block multipliers x2 (/56) and x4 (/48), connection caps at most a
  sixteenth/eighth of all; global publish and download budgets eight times
  a /48's share
- problems capped at 200; writes refused below max(1 GiB, two files) free;
  the private-server file cap documented
2026-10-12 11:40:03 +07:00
bflatastic 7edc61b8d2 M9: harden open hosting against resource exhaustion
Fixes from the security review of the public server:
- team libraries only in key-published files on open servers; unpacking
  bounded by the file cap (zip bombs refused)
- byte-bounded send queues per connection and in all, a bound on bytes
  waiting for the hub, per-address and global download budgets
- batches checked against the file, per-address and total caps as files
  grow (refused with a Reject the app rolls back); the core's recent-batch
  memory bounded in bytes; nothing confirmed when a log can't be written
- usage reports kept in memory; per-kind message caps checked while read
- layered IPv6 limits (/64, /56, /48), a global publish rate, connections
  reserved for sessions, short idle timeout for one-shot requests
- revoked links and removed files cut library connections
- data directory 0700, server key 0600 from creation
- install.sh: SSH ports from sshd, ufw left off when other services
  listen, unprivileged builds of the latest tag, settings checked before
  use, own Caddy snippet; systemd memory and network caps; backups check
  free space and say they hold the key
- linger reads raw bytes after an oversized frame (tungstenite asserted)
2026-10-12 11:40:03 +07:00
bflatastic faf2e78c9d M9: install.sh, backup timer and docs for a public figcraft-server
packaging/server/install.sh sets up Debian/Ubuntu behind Caddy: binary from
its release archive, the source tree it sits in, the latest release or a
clone (temporary swap under 2 GB of memory), user, dirs and config only when
missing, systemd unit, Caddy site, nightly backup timer, ufw for 22/80/443;
safe to re-run for upgrades. docs/server.md gains Running a public server
(limits, expiry, moderation, operator responsibility, no end-to-end
encryption, threat model) and an install.sh walk-through; protocol, MCP,
CLI and parity docs updated.
2026-10-12 11:40:03 +07:00
bflatastic b7af7c7ef7 M9: open hosting mode for public figcraft-server
Config-gated (open_publish): anyone publishes without the server key and
gets an owner token (128-bit, shown once, stored hashed) that manages only
that file. Per-address limits (X-Forwarded-For only from trusted_proxy,
bounded tables), global caps, idle expiry, reports, persisted blocks,
files list --sort, check, GET /healthz on the same listener, startup
warnings. The app asks the server first, publishes without a key when it
is open, shows the owner token once, the host/terms line, the expiry date
and a report button; collab.serverInfo, collab.manageFile, collab.report
and pref.publishServer for MCP/CLI. Protocol additions stay within
version 2, gated like signals.
2026-10-12 11:40:03 +07:00
bflatastic f2802cca02 M7: headless renders use system fonts
Headless runs (`figcraft-cli export`, `render`, `convert`, `run`, and the headless MCP server) never indexed the installed fonts, so a text layer in an installed family (Segoe UI, Inter installed per user, ...) was measured and drawn with a fallback face, while the desktop app used the real one. Auto layout sizes from MCP agents therefore differed from what users see in the app.

- `figcraft_text::scan_system_fonts_on_demand()`: the font database indexes the system fonts the first time it is used (first text layout, font lookup or font listing). `figcraft-cli` turns it on at startup for every subcommand, so a run that touches no text never pays for the scan. The desktop app keeps its background scan.
- `scan_system_fonts()` now runs once per process (a `OnceLock`): racing callers wait for the one directory walk instead of each walking the folders.
- Opt-out for reproducible renders: `--no-system-fonts` on any subcommand, or `FIGCRAFT_NO_SYSTEM_FONTS=1` (honoured by the desktop app too). Text then uses only the built-in fonts, as before.
- The scan is cheaper: it reads each file's headers, table directories and `name` tables instead of whole files (about 460 MB on this Windows machine), and takes file metadata from the directory listing instead of a lookup per file. This also speeds up the desktop app's background scan.
- Browser/wasm build unchanged (the scan is a no-op there).
- Docs: `docs/cli.md` (new Fonts section) and `docs/mcp.md` (Fonts under the backends table); CLI usage text mentions the flag.
2026-10-12 09:32:42 +07:00
bflatastic d988a30607 Docs: README showcase screenshots and example files 2026-10-12 08:57:26 +07:00
bflatastic e01f4c1a43 Docs: shortcut counts, draft coverage and the box-selection label after the polish pass 2026-10-12 07:53:24 +07:00
bflatastic 6d1804ddc5 M9: draft catalogs catch up
Every draft catalog translates every string again.

- French, German, Brazilian Portuguese, Japanese and Korean: the 66 UI strings and 17 command labels
  added since they were drafted (cursor chat and spotlight, the plugin directory and its install
  dialog, Rename layers, the presentation, snapping and panning gesture labels, list levels, the
  rotation origin and placing with Enter, widgets, `view.pan`, `view.multiplayerCursors`,
  `proto.evaluate`...).
- Simplified Chinese: the 46 UI strings and 12 command labels it lacked.
- Latin American Spanish overlay: only the entries worded differently there, "Control" for a
  gamepad controller and "presiona" in the plugin directory hint (90 entries with the Enter keycap
  added in the previous commit).
- Translated from our English in each file's existing conventions (French no-break spaces before
  colons, German formal address and "Umschalt"/"Strg", Brazilian terms, concise Japanese and Korean
  without polite endings, full-width punctuation in Japanese and Chinese, keycaps and placeholders
  kept). The catalogs stay drafts: `COMPLETE` still lists only Spanish.
- docs/localization-parity.md: strings re-measured (1,417 UI keys, 536 command labels, 29 menu
  wordings: 1,982 entries), every draft at 100%, the "lacks the newer strings" notes removed, header
  and revision row. Localization grade unchanged.
2026-10-12 07:53:24 +07:00
bflatastic 29e31c2054 M1: rotation origin and place-with-Enter shortcuts
The last two documented shortcuts the parity table listed as missing, and a stale toast.

- **Alt+R: rotation origin** (both platforms). `transform.rotationOrigin {on?}` shows (or hides) a
  handle at the selection's rotation origin: a ring with a cross through it, drawn in code. Drag it
  to move the origin (it snaps to the selection box's corners, edge middles and centre within the
  snap distance); double-click it to put it back at the centre; Escape hides it before it deselects,
  and Alt+R again hides it too. `transform.setRotationOrigin {x, y} | {reset: true}` does the same
  from the CLI, MCP and the control channel. The corner rotation handles, the rotation field
  (`node.set {rotation}` on the selection: each layer's point under the origin stays put, so flipped
  layers work too), Rotate 90/180 and `transform.rotate` without `cx`/`cy` turn about it. The
  origin is stored on the document state per selection, in the first layer's own coordinates, so it
  follows the layers as they move and turn; it is dropped when the selection changes and is not
  saved in the file (the public documentation doesn't say it persists; pinned in the parity row).
- **Enter places an object with the chosen tool.** With a shape or frame tool chosen, Enter on the
  canvas, or Enter again on the toolbar's chosen tool (after picking it from the toolbar with the
  keyboard), places what a click with the tool makes (100x100; lines 100 long; sections 400x300, the
  existing click sizes, now one `ToolId::click_size`) centred in the view, selected, as one undo
  step; the canvas then takes the keyboard. New `tool.place {tool?, x?, y?}`; MCP's headless `key
  Enter` does the same. The documented crosshair that the arrows move before placing is not built.
- **Keyboard box selection toast** (and its screen-reader text) now names both ways to size the box:
  "Shift+arrows or Cmd+arrows" (Ctrl on Windows and Linux). The changed key is renamed in every
  catalog with its translation updated.
- **Shortcuts panel labels:** a label too long for its column was measured in a smaller font than
  it is drawn in, so long ones (several Spanish rows) ran into their keycaps; it is now measured in
  its own font, ends with "..." before the keycaps and shows in full on hover. The new Tools row
  reads "Place a shape or frame (its tool chosen)" with an Enter keycap.
- **Parity table:** the two rows move from Missing to bound (Alt+R -> `transform.rotationOrigin`;
  Enter -> a Tools-tab gesture row, the keymap's Enter staying `select.children`); counts 129
  documented, 128 bound, 1 moved, 0 missing, 2 kept by a platform, 48 pinned.
- New command labels in Spanish (`tool.place`, `transform.rotationOrigin`,
  `transform.setRotationOrigin`), new UI strings in English and Spanish (es-419 overlays "Enter").
- Docs: ui-parity.md (selection and transform rows, shortcut counts, header, revision row),
  mcp.md (the new commands).
2026-10-12 07:53:24 +07:00
bflatastic c420721597 M6: experimental .fig export
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.

- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
  - `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
  - File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
  - `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
  - MCP's `export` accepts `format: "fig"` and passes the warnings through.
  - New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
2026-10-12 07:19:57 +07:00
bflatastic 8d5f8a0978 M1: keyboard shortcut parity suite and bindings
- A shortcut parity table (`crates/engine/src/shortcut_parity_tests.rs`): one row per shortcut in
  the public documentation (help-centre feature articles: keyboard layouts, text properties,
  alignment, frames, components, prototypes, plugins, keyboard navigation), with the macOS and the
  Windows/Linux keys, what handles it here (keymap command, held key/gesture listed in the panel,
  vector edit tool key, or missing), and a note in our own words. Rows the documentation is silent
  on pin today's keys, so every default binding is in the table.
  - 129 documented rows: 126 bound (110 before), 1 moved off a documented conflict (Run last
    plugin on Windows: the docs also give Ctrl+Alt+P to pixel preview; the plugin keeps
    Ctrl+Alt+Shift+P), 2 missing (moving the rotation origin with Alt+R; placing an object with
    Enter after choosing a tool from the toolbar), 2 kept by a platform (Ctrl+Space with some CJK
    input methods; F6 in browsers). 48 undocumented bindings pinned.
  - Tests: every row fires its command on both platforms and sits in the right shortcuts-panel tab;
    every default binding has a row; no binding uses a chord the OS keeps (Cmd+H/M/Q/Tab/Space,
    Ctrl+Space, screenshot keys on macOS; Alt+Space/Tab/F4, Ctrl+Esc, Ctrl+Alt+Delete, Linux
    workspace and VT keys); macOS keycaps show Cmd/Option glyphs only and Windows/Linux keycaps
    Ctrl/Alt words only; vector edit tool keys; counts pinned for the docs.
- Newly bound: A (Frame tool), Shift+' (pixel grid; Cmd+' kept, the articles disagree),
  Alt+Cmd+\ / Ctrl+Alt+\ (others' cursors in a live session), Ctrl+F6 (toolbar focus on
  Windows/Linux), Shift+Cmd+U (link text; Cmd+K kept), Cmd/Ctrl+] and [ (list levels while
  editing), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection; F8 kept,
  Cmd/Ctrl+arrows draw the box out), Ctrl+Shift+A / Ctrl+Alt+Shift+A (suggest auto layout),
  Ctrl+Alt+Cmd+K / Ctrl+Alt+Shift+K (go to main component).
- New commands: `view.multiplayerCursors {on?}` (View menu item, menu breadth 207/207),
  `view.pan {dx, dy, big?}` (arrows pan the view by 20 screen px, 200 with Shift, when nothing is
  selected; the canvas and MCP `key` both use it), `text.listIndent` / `text.listOutdent`.
- Behaviour: Control held while dragging suspends snapping on macOS too (Cmd/Ctrl already did);
  the presenter takes Space and N for the next frame, C for comments (Shift+C kept) and F for full
  screen, after the prototype's own key triggers; the paint bucket's tooltip shows Shift+B (B still
  works).
- Keyboard layouts: shortcuts follow the character typed, named as on a US keyboard. A symbol
  another layout types with Shift (`/` is Shift+7 on German keyboards) now counts without the
  Shift (canvas, text editing and F6 navigation share one lookup). Characters egui has no key for
  already arrive as the key at that position.
- Keyboard shortcuts panel: new rows for panning with the arrows, suspending snapping, moving along
  the toolbar, and the presentation keys, in the tabs the documentation groups them in (en/es
  strings).
- Docs: ui-parity.md gets a Keyboard shortcuts section with the counts; target-app-parity.md,
  ROADMAP.md, gaps.md (gap 4 landed list), mcp.md, parity-checklist.md (regenerated).
2026-10-12 06:49:02 +07:00
bflatastic 01bc5a9dac M11: plugin installs saved at once; directory and library requests off the UI thread
- **Installed plugins survive a crash.** Importing, installing, updating or removing a plugin, and a
  plugin changing its settings, now sets `Runtime::unsaved`; at the end of each frame the app hands
  the preferences to a new `Services::save_prefs`, and the desktop app writes `state.json` at once
  (temporary file + rename, the same atomic write the quit path uses, now shared). Nothing waits for
  quit any more.
- **The UI never waits on the network.** New `Session::start_background(id, params)`: the command
  runs as usual until it makes a server request; the request goes out on the link's own thread
  (`LiveLink::background`, implemented by the desktop `NetLink` with one short-lived thread per
  request) and the command stops; when the answer arrives (`Event::Net`, taken by `live_poll`, which
  the network's waker already triggers) the command runs again from the start on the file it
  started on, answered from the answers so far, until it needs no new request. All server requests
  go through one funnel (`Session::net`), so `plugin.directory.*`, `library.publishToServer`,
  `library.add` (links), `library.usage` and library fetches work this way without being split.
  A re-run of `library.publishToServer` caches the library it actually sent (a fresh copy differs
  in its time and, on a first publish, its id). One job per command; `cancel_background` drops a
  late answer.
- **UI:** `FigApp::run_background(ctx, id, params, done)` with a boxed completion. Manage plugins >
  Browse shows "Fetching from the server..." with Cancel instead of Browse; the install confirmation
  shows "Installing..." with Cancel and stays open until the download is in (an error leaves it open
  to retry); row buttons wait during an install; closing the window cancels. Assets > Libraries:
  Publish to server shows "Publishing..." + Cancel, Show usage and pasting a team library link show
  "Fetching from the server..." + Cancel. No polling repaints: the window draws again when the
  answer's waker fires; pending state is a scan of a short Vec, no per-frame allocation.
- **Programmatic callers are unchanged:** MCP, CLI, control channel and scripts still run the same
  commands synchronously (and the browser link, which has no background requests, too).
- New string "Installing..." (en/es); docs: plugins.md (immediate saves, Browse/Install never wait),
  server.md (team library requests never wait).
2026-10-12 06:15:00 +07:00
bflatastic 9f13cb4ba2 M9: Simplified Chinese draft catalog
- `crates/ui-egui/locales/zh-Hans.toml`: Simplified Chinese, translated from our English catalog:
  every UI string (1,371 keys), command label (524) and menu wording (29), 1,924 entries. Concise UI
  style, full-width punctuation in Chinese text, a space between Chinese and Latin words or numbers;
  plural entries give only `other`. `[meta] status = "draft — needs native review"`; registered as
  a draft (not in `COMPLETE`), like fr/de/pt-BR/ja/ko.
- Preferences > Language shows the native name (Simplified Chinese in Chinese characters).
- Detection: zh, zh-CN, zh-SG, zh-Hans, zh-Hans-CN (and zh-Hans-HK) -> zh-Hans. Traditional Chinese
  (zh-Hant, zh-TW, zh-HK, zh-MO, zh_TW.UTF-8) has no catalog: it resolves to English, or to the next
  OS language in the list, never to Simplified (before this, the regional fallback would have picked
  zh-Hans for zh-TW).
- `plural_one` never picks "one" for zh-Hans.
- CJK UI fonts (`cjk.rs`, `theme.rs`, `lib.rs`):
  - `cjk::samples` takes the UI language's scripts: a Chinese UI puts Han first, looked up in the
    Chinese faces first (Noto Sans CJK SC, Microsoft YaHei UI / YaHei, PingFang SC, ...), even when
    the document also has kana.
  - `install_fonts`: in a Chinese UI the craft-fonts build's built-in Japanese BIZ UDPGothic goes
    after the system faces and doesn't count as drawing Han, so Han takes Simplified Chinese shapes
    instead of Japanese ones (and characters BIZ lacks no longer mix in from another face).
  - The installed CJK faces are keyed by UI language too, so switching languages lays them out
    again (before: switching from Japanese to Chinese kept the Japanese face in front).
- `pref.language` params doc and `docs/mcp.md` list zh-Hans.
- Docs: localization-parity, translating, target-app-parity, ROADMAP, gaps (gap 5), roadmap (focus
  item 4). String counts re-measured from source: 1,371 UI keys, 524 command labels, 29 menu
  wordings (1,924 entries; the docs said 1,899); fr/de/pt-BR/ja/ko drafts have 1,899 of them (25
  strings added since read English), graded 99%. Localization 36% -> 39%, 31-54 h -> 29-51 h; ready
  for real work unchanged (~53%); full parity ~267-527 h -> ~265-524 h.
2026-10-12 05:57:48 +07:00
bflatastic 08df188e1a M9: Latin American Spanish overlay
- `crates/ui-egui/locales/es-419.toml`: Latin American Spanish as an overlay on `es.toml`. It lists
  only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American
  usage: agregar for anadir, presionar for pulsar, configuracion for settings, Enter for the Intro
  key, control for a gamepad, resorte for a spring. Everything else reads `es.toml`, then English.
  Same tu register as `es.toml`. Marked as a draft awaiting native review (not in `COMPLETE`).
- Fallback chain: a regional catalog whose language has a catalog overlays it. `build.rs` bakes a
  `parent` index into each `Lang` (es-419 -> es; others -> English), and `i18n::chain` walks
  overlay -> base -> English for UI strings, command labels and menu wordings. Catalogs are now
  sorted by code, so the picker lists Espanol before Espanol (Latinoamerica).
- System detection: es-419, es-MX, es-AR, es-CO, es-US and the other Latin American tags (also
  `es_CO.UTF-8`, `es-Latn-MX`) resolve to es-419; es, es-ES and other regions stay es.
- Preferences > Language shows "Espanol (Latinoamerica)" (in Spanish spelling) with the draft note.
- `pref.language` params doc and `docs/mcp.md` list es-419.
- Docs: localization-parity (overlay section, language preference, Spanish row, total), translating
  (overlays, detection, wording), target-app-parity, ROADMAP, gaps (gap 5), roadmap (focus item 4).
  Localization 35% -> 36%, 32-55 h -> 31-54 h; ready for real work unchanged (~53%); full parity
  ~268-528 h -> ~267-527 h.
2026-10-12 05:57:48 +07:00
bflatastic 119b8c7441 M5: prototyping tail: gamepad triggers, widgets in prototypes, mode lookups
The part of gap 15 that doesn't need video, from the public documentation of prototype triggers, plugins/widgets and prototype expressions.

- **Controller buttons on key/gamepad triggers.** A key trigger's key can be a controller button: `Gamepad A` ... `Gamepad Home`, the standard gamepad layout's 17 buttons (face buttons by position, shoulders, triggers, View/Menu, stick presses, d-pad, Home). The engine keeps its input-agnostic model (a key trigger with a key name; `figcraft_doc::proto::GAMEPAD_BUTTONS`, `gamepad_button`, `is_gamepad_button`, `uses_gamepad`). The interaction editor gets a Controller menu beside the key field; the trigger list shows the button's own name.
- **Reading controllers.** A new `Services::gamepad_held()` (bitmask of held buttons; default none). The player polls it only while the presented prototype has a controller trigger and the window has the focus (about 30 times a second), plays each press once (held buttons don't repeat), and fires the first matching key/gamepad trigger on the screen like a key.
  - Desktop, Windows: Windows.Gaming.Input (Xbox-compatible controllers) through the `windows` crate the app already depends on (one more feature flag, `Gaming_Input`; no new crate).
  - Browser: the Gamepad API through `web-sys` (features `Gamepad`, `GamepadButton`, `GamepadMappingType`; standard-mapped controllers only).
  - macOS, Linux, FreeBSD: no controllers yet (documented gap).
  - Control channel: `ui.input {events: [{gamepad: "A" | "Gamepad A" | 0}]}` presses a button in the prototype playing (presenter, else preview pane); an error when nothing plays.
- **REST import** maps controller triggers (`device` other than `KEYBOARD`) to the button at the code's standard-layout index instead of skipping them; two buttons at once or an unknown code is skipped with a warning (`.fig` goes through the same path).
- **Widgets react to clicks in a presented prototype.** A click on a widget's clickable part in the presenter or the preview pane (when no interaction on the layer takes it) calls the plugin back through the canvas's own callback with the same sandbox limits, on the player's copy of the file (`widgets::click_on`, a scratch session holding the copy and the installed plugins): the widget changes in the prototype only, the file and its undo history stay untouched, restarting starts the widget over. Failures (plugin missing or failing) toast; toasts now also show over the full-window presenter.
- **Variable mode lookups.** Lookups (`accent[Dark]`, `mode(Theme)`), set-variable-mode affecting later expressions in the same interaction, and every operator the documentation lists already held; they are now behaviour cases. New `proto.evaluate {expression | expr, id?}` (MCP and agents) computes an expression where a layer's interaction would run, without changing anything. No other cheap expression gap found: every documented operator (numbers, text join, booleans, comparisons, negation, mode lookup) already existed.
- Docs: prototyping-parity (new rule rows, features, revision), target-app-parity area 13 and totals, ROADMAP, gaps.md gap 15, roadmap.md M5, mcp.md, plugins.md, control-protocol.md.

Dependencies: none added. Windows uses the existing `windows` 0.62 dependency with the `Gaming_Input` feature; the browser uses the existing `web-sys` with three more features. The usual Rust controller library (gilrs) was not taken: its Linux backend links the system's libudev (a C library, needing headers on CI and the FreeBSD job), which breaks the pure-Rust rule.
2026-10-12 05:28:53 +07:00
bflatastic def6f84b22 M11: plugin directory on figcraft-server
A self-hosted plugin directory, with no third-party service: `figcraft-server` keeps plugin bundles,
the app browses, installs and updates from it, and `figcraft-cli` publishes to it.

- **Bundles** (`figcraft_plugin::bundle`): a plugin's manifest, module and up to 32 plain-named
  assets in one uncompressed, length-prefixed binary form (nothing can expand beyond the bytes
  sent). Its SHA-256 names it. `Bundle::decode` never allocates beyond its input and refuses
  trailing bytes; `Bundle::check` parses the manifest (refusing a plugin API this build doesn't
  run), requires a version of 1-4 numbers, compiles the module without running it (only the
  sandbox's imports allowed) and checks asset names (no paths, no dot files, no Windows device
  names, unique ignoring case). Manifests gain an optional `author`.
- **Protocol**: a new connection kind next to managing and team-library connections:
  `OpenPlugins {key}` then `Plugins {list | fetch | publish | unpublish}`. Browsing needs no
  credential; publishing and unpublishing need the server key (a wrong key is refused). Older
  servers refuse the connection and the app says the server has no plugin directory. The protocol
  version is unchanged; old clients are unaffected.
- **Server** (`apps/figcraft-server/src/directory.rs`): `<data>/plugins/index.json` plus
  `<data>/plugins/<sha256>.bin`, so no publisher-chosen name reaches the file system. Every publish
  is checked in full and must be a newer version; caps `max_plugins` (versions, 0 turns it off),
  `max_plugin_mb` (<= 32), `max_plugins_mb`. Fetches re-hash the file before sending (a damaged
  bundle is never sent) and share a download budget (~32 MiB/s) so a flood is refused, not queued.
  Damaged index entries are skipped and reported, never deleted; an unreadable index lists nothing
  and refuses writes until the operator fixes it. Operator moderation:
  `figcraft-server plugins [list]` and `plugins remove ID [--version V]` (offline or through the
  running server). Logs name ids and versions, never keys.
- **App**: Plugins > Manage plugins gets Installed / Browse tabs. Browse lists a server's plugins
  (version, author as typed, size, access) with a search; nothing is fetched until Browse is
  pressed. Install / Update open a confirmation listing the permissions asked for, the source and
  the SHA-256; only its button downloads. The download must match the listed SHA-256 (and the one
  shown), then is checked like an imported plugin before anything is written. Installed plugins
  live per user under `<settings>/FigCraft/Plugins/<hash of id>/` and run in the same sandbox;
  Remove deletes the folder.
- **Commands**: `plugin.directory.list {server?, query?}`, `plugin.directory.install {id, version?,
  confirm, server?, sha256?}` (refused without `confirm: true`; never opens a dialog),
  `plugin.directory.publish {path | plugin, server}`, `plugin.directory.unpublish {id, version?,
  server}`. `ui.set {pluginsTab, pluginConfirm}` on the control channel.
- **CLI**: `figcraft-cli plugin publish FOLDER --server ws://host:port/` with the key from
  `FIGCRAFT_SERVER_KEY` (or `?key=`), never printed.
- Docs: plugins.md (directories, publishing, commands, manifest), server.md (operating the
  directory: access, checks, caps, moderation, backups, security), collab-protocol.md, mcp.md,
  cli.md, control-protocol.md, target-app-parity area 18 and Ecosystem, ROADMAP, roadmap.md M11,
  gaps.md gap 6. Plugins & widgets 55%/32% -> 60%/35%, 10-30 h -> 8-26 h.
2026-10-12 05:28:53 +07:00
bflatastic 159de3ce43 M9: Japanese and Korean draft catalogs and CJK UI fonts
- **Japanese (`ja`) and Korean (`ko`) draft catalogs** (`crates/ui-egui/locales/ja.toml`, `ko.toml`): every UI string (1,351 keys), command label (519) and menu wording (29), machine-drafted from our English (Japanese: concise, no desu/masu endings, full-width punctuation where Japanese UI text uses it; Korean: no hamnida endings, instructions in -seyo). Placeholders identical to English, `|context` keys translated per context, plural entries give only `other`. Marked `status = "draft - needs native review"` and registered as drafts (not in `COMPLETE`), like fr/de/pt-BR.
- **Plurals:** `plural_one` returns false for ja and ko (always "other").
- **Language picker and detection:** Preferences > Language lists the two languages by their own names; System picks `ja*` / `ko*` from the OS language list; `pref.language` doc lists `ja|ko` (engine spec and docs/mcp.md).
- **CJK UI fonts** (`crates/ui-egui/src/cjk.rs`): egui's fonts and Inter have no CJK glyphs. After the existing system font scan, the desktop app works out which CJK scripts (kana, Hangul, Han) the UI shows: the UI language's, the language picker's while Preferences is open, typed/IME text, and the open file's names and comments (re-read when the tab's revision changes, not mid-drag, and never again once all three scripts have a face). For each needed script it appends one face to egui's fallback list, the UI language's script first: Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux/BSD Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's embedded BIZ UDPGothic is tried first (free). Han without kana outside a Japanese/Korean UI reads as Chinese (Chinese faces first). English users load no CJK face. No font files are added to the repo.
- **Read once, share the bytes:** new `FontDb::find_one` reads only the one face a fallback needs instead of every file of the family (Yu Gothic UI is 4 files of ~14 MB); `theme::font_data` hands egui the font database's shared bytes instead of copying them (also used for the existing Hebrew/Arabic fallbacks). The scan's existing 64 MB per-file cap applies. Any failure degrades to the previous behaviour (tofu at worst), never a panic.
- **Web:** no system fonts there. `crates/text/build.rs` now embeds craft-fonts' Noto Sans CJK into the wasm build when `FIGCRAFT_EMBED_CJK` is set (it covers kana, Hangul and Han; +16 MB) and reruns when that variable changes. Without it, `cjk::can_show` keeps ja/ko out of the picker and out of System detection, so Japanese and Korean browser users keep the English UI instead of boxes. Nothing is fetched from the network.
- **Docs:** localization-parity (rows, CJK UI fonts section, totals, revision), translating (catalog list, CJK conventions, fonts, plural note), target-app-parity, ROADMAP, gaps (gap 5), roadmap (focus 4), mcp.md. Localization 30% -> 35% (graded low: unreviewed drafts), 40-65 h -> 32-55 h; ready for real work ~52% -> ~53% (52.9 -> 53.1 by the dimension table); full parity ~284-554 h -> ~276-544 h; beta ~162-292 h -> ~155-280 h.
2026-10-12 04:41:28 +07:00
bflatastic 207373be15 M11: plugin widgets and the plugin panel tail
Widgets for FigCraft's own WebAssembly plugins (plugin API 3; API 1 and 2 plugins run unchanged), plus the panel tail.

- **Widgets.** A manifest with `"api": 3, "widget": true` makes a widget plugin. Plugins > Widgets (or `widget.insert {plugin, x?, y?}`) puts its widget on the page: the plugin is called with `widget: {id, state, event}` and hands back a description through the new `widget_render` import (`{state?, root}`: frames with auto layout, text, rectangles, ellipses and images it carries as base64). FigCraft validates the description (4 MB, 256 layers, 16 deep, finite numbers within ranges, hex colours, text length and control characters, a 16 KB state, images 1 MB each / 2 MB together / 4096 px a side) and builds it as a frame whose layers are locked, so the widget moves as one layer.
- **Clicks.** A plain click with the Move tool on a part the plugin named as a `target` (the cursor turns into a pointing hand there; quick repeated clicks each count) calls the plugin back in a fresh, metered sandbox (same fuel, memory and call limits as runs; nothing kept between calls, so the state and the click alone decide the result) as one undo step, and the widget is redrawn. Dragging moves it; Shift/Cmd/Alt clicks only select. Agents use `widget.click {id, target}` and `widget.list`.
- **State in the document.** The plugin id and the state live in the widget's own layer data (`figcraft.widget*`), so widgets survive save and reload, duplicate/copy and paste (the copy is a widget of its own), undo/redo, export (the built layers draw) and live sessions (synced like any layer data). No plugin can write `figcraft.widget*` data or call `widget.*`; a callback can only rebuild its own widget, and touches other layers only through the commands its manifest permits. A widget whose plugin is missing stays as drawn.
- **Example.** `plugins/examples/counter`: a counter between - and + buttons (read access, no assets). The SDK gained `Input::widget` and `render_widget`.
- **Panel tail.** Panels of several plugins are open at once, side by side (`plugin.ui` without an id returns the one opened last); a colour field's swatch opens FigCraft's colour picker beside the plugin's window (the plugin hears of the colour when a drag ends; also for parameters forms). Swatch grid's palette panel gained a label colour field.
- Docs: plugins.md (ABI v3, widget description, commands, limits), mcp.md, target-app-parity area 18, ROADMAP, gaps gap 6, roadmap M11; Spanish labels for the new commands.
2026-10-12 04:41:28 +07:00
bflatastic 1e0b51398a M9: French, German and Brazilian Portuguese draft catalogs
- New UI catalogs `crates/ui-egui/locales/fr.toml`, `de.toml` and `pt-BR.toml`: every UI string (1,351 keys, 36 with plural forms), every command label (519) and every main-menu wording (29) translated, 1,899 entries each. Machine-drafted from our English, marked `status = "draft - needs native review"` like Spanish, so Preferences shows the draft note. Placeholders are identical to English; `|context` keys are translated per context (sizing "Fill", stroke "Join", glass "Light", open comments).
- Language conventions: French uses no-break spaces (` `) before `: ; ? !`, inside guillemets and before `%`; German uses formal address and German quotes; Brazilian Portuguese uses Brazilian vocabulary (arquivo, salvar, tela, voce).
- Not added to `COMPLETE`: that list forces every later PR to translate new strings into each complete language, so the three drafts are registered as incomplete and fall back to English per key; Spanish stays the only enforced language. `docs/translating.md` says so and how to list what a draft lacks.
- Plurals: `plural_one` now takes 0 as singular for French and Brazilian Portuguese (CLDR integer rules); English, Spanish and German stay `n == 1`.
- Language choice: Preferences > Language lists Francais, Deutsch and Portugues (Brasil) by their own names (from the catalogs). System now walks the OS's preferred-language list and takes the first language with a catalog: Windows' full `GlobalizationPreferences.Languages`, all of macOS's `AppleLanguages`, gettext's `LANGUAGE` then the locale variables on Linux/BSD; the browser still uses `navigator.language`. A tag with only a regional catalog reads it (`pt-PT`, `pt` -> `pt-BR`). `pref.language` docs list `fr|de|pt-BR`.
- Overflow (German is the longest): `widgets::dropdown` now keeps its label on one line, ending it with an ellipsis and showing it in full on hover (it used to wrap mid-word, e.g. the auto layout sizing and stacking dropdowns); `elided_dropdown` merged into it. Two-column sublabels in the right panel and the home screen's cards elide the same way. English is unchanged.
- Docs: localization-parity (languages, string counts, status, hours), translating, target-app-parity, ROADMAP, gaps (gap 5), roadmap (current focus 4), mcp. Localization 20% -> 30%, 45-75 h -> 40-65 h; ready for real work stays ~52%; full parity ~289-564 h -> ~284-554 h; beta ~170-305 h -> ~162-292 h.
2026-10-12 04:19:58 +07:00
bflatastic 1f6e75e0b6 M8: cursor chat and spotlight in live sessions
Two presence features for live sessions, with no accounts needed:

- **Cursor chat.** In a live session, `/` on the canvas opens a bubble in your colour at your pointer. What you type goes to everyone as you type, and others see it next to your cursor in place of your name tag. Enter (or clicking away) sends it and closes the box. On every screen it fades a few seconds after its last change (5 s, then a 1 s fade; with reduced motion it vanishes without fading). Escape takes it back at once and doesn't deselect. A box left open without typing closes by itself. Limits: 100 characters, control characters become spaces. Agents use `collab.cursorChat {text}`.
- **Spotlight.** Your avatar (top of the right panel) now has a menu with Spotlight me / Stop spotlighting (`collab.spotlight {on?}`). Everyone else follows your view and page, using the existing follow. People who join later are told by the server and follow too. Anyone who pans, zooms or presses Escape stops following, and a canvas banner ("Hana is spotlighting", Follow) lets them follow again. A newer spotlight takes over the old one. The presenter leaving or stopping ends it. The presenter sees a banner with Stop. After a reconnect, your spotlight goes out again.
- **Protocol: signals.** New `ClientMsg::Signal {kind, text}` and `ServerMsg::Signal {client_id, signal}` carry ephemeral presence. Signals never go into the document, the log or a saved state; the server keeps only who is spotlighting, for newcomers. The server cleans each signal: unknown kinds, malformed spotlights, a client's `ready` and anything over 1 KiB are dropped, and chat is capped and has control characters replaced. The app checks what arrives again before showing it. The hub rate-limits signals per connection (20 a second, bursts of 40) and drops the excess without closing the connection.
- **Compatibility, both directions.** A client that reads signals announces it with the word `signals` in its hello's `client_version`, and the server sends signals only to those clients, so older clients never see a message they can't decode. A server that takes signals answers with a `ready` signal after the welcome, and the client sends signals only after that, so older servers never get one; the app says the host runs an older version. Signals are kind plus text, so clients skip kinds added later and servers drop them.
- Engine: `Live` keeps `signals`, `chats`, `chat`, `spotlight` and `spotlighting`, all reported by `collab.peers`. UI: chat bubbles, the chat box, the spotlight banner (the existing canvas banner widget), the avatar menu and `ui.inspect` fields. Keymap: `/` maps to `collab.cursorChat` (Essential). Spanish labels for both commands, plus en/es UI strings.
- Docs: collab-protocol (messages, compatibility, Presence, security limits, agents), mcp.md, collaboration-parity rows, target-app-parity, ROADMAP, gaps, ui-parity.
2026-10-12 03:47:34 +07:00
bflatastic 626a9eac08 M1: layers and assets panels behaviour suite and fixes
A behaviour suite for the layers panel and the Assets panel written from the public documentation
(table rows with one-line derivations; rows the documentation is silent on say so and pin today's
behaviour), and the fixes it found.

- Engine suite `crates/engine/src/panels_behaviour_tests.rs` (6 tests, about 50 rows): reordering
  and moving layers into frames, groups and auto layout, refused drops, renaming several layers,
  show/hide and lock rules, pages and dividers, Assets folders, hidden-from-publishing names, drops.
- Panel suite `crates/ui-egui/src/layers_behaviour_tests.rs` (5 tests, driven through egui
  headless): click / Shift range / Cmd-Ctrl toggle, Alt-click expand, Collapse layers, sweeping
  across eyes and padlocks, the Rename layers dialog, dragging pages, dividers.

Fixes:
- Several layers dropped together in the layers panel land together at the drop point (layers
  from apart split around it before).
- Collapse layers (Alt+L) keeps the rows leading to the selection open (it closed everything).
- The eye and padlock no longer vanish when the pointer reaches them (the row lost its hover to
  them, so clicks could miss); dragging across eyes or padlocks sets every row passed in one undo
  step. Layers inside a locked frame show a faint padlock.
- Several layers selected + Cmd/Ctrl+R / F2 / Rename open a Rename layers dialog (Match, Rename to,
  Current name / Number up / Number down buttons, start value, preview) instead of renaming the top
  row only.
- `object.batchRename`: `$n` counts up and `$N` down (they were swapped), `$nnn` pads, `start`
  sets where numbers start/stop, `regex: true` matches a regular expression with `$1`..`$9`,
  `` $` `` and `$'`, and codes work in the replacement too. Returns the new names.
- Pages: dragged up and down the list to reorder; a blank page named with a leading hyphen is a
  divider (drawn as a line, never opened, holds no layers); duplicates are named "Copy of ...";
  the last page that isn't a divider can't be deleted. `page.list` reports `divider`.
- Assets panel: components filed in folders by page, the frames holding them and the slash parts
  of their names (folders fold away; open while searching), tiles show the last name part;
  `component.list` gives each component's `folder` and `label` for agents.
- Publishing: a leading `.` or `_` keeps a component (style, collection) out of a library even
  after it was published, and removing the prefix brings it back (the previous publish's choice
  won before).

The engine gains the `regex` crate (already in the lock file through env_logger) for the
documented regular-expression matching.
2026-10-12 03:47:34 +07:00
bflatastic a5a706674c M4: vector multi-edit and edit tail
Vector edit mode can now edit several vector layers at once, and the rest of the vector edit tail
from the public documentation landed. A behaviour suite came first
(`crates/engine/src/vector_multi_behaviour_tests.rs`, 5 behaviour tests with about 30 table rows and
steps, plus a junk-parameter sweep of every `vector.*` command run in multi-edit mode).

- **Multi-edit.** Enter, a double-click or `vector.edit.begin {ids}` with several shapes selected
  puts them all in edit mode, converting them in one undo step. The clicked or first one is the
  active layer. Pressing another layer's point, handle or segment makes that layer active. Shift-click,
  marquee and lasso select points in any layer. Dragging a selected point, the arrows, Delete,
  delete and heal, mirroring, smoothing, corner radius and the new cap and join commands reach every
  layer's selected points in one undo step. The pen, bend, paint bucket, cut, eraser, join and
  position work in the active layer. A click away from every edited layer leaves edit mode.
  `vector.edit.state` now reports `layers`, `others` and `handles`.
- **Several handles.** Shift-click Bezier handles to select more than one. Dragging one moves the
  others by the same amount. Also available as `vector.edit.select {handles}` and
  `vector.edit.moveHandles`. Selected handles are drawn filled.
- **Rounding next to curves.** A sharp point with a corner radius rounds even when one of its
  segments is curved. The curve is cut where the arc meets it and keeps the rest of its shape, and
  the arc meets each side smoothly (`VectorNetwork::rounded`).
- **A cap and a join per point.** `Vertex` gets optional `cap` and `join` values. When unset, the
  layer's apply. The commands are `vector.edit.strokeCap` and `vector.edit.strokeJoin` (`null`
  clears). The Design panel's Point section gets End point and Join dropdowns, each with a "Same as
  layer" option. The stroke is drawn run by run and each point's own join is drawn over the break
  (`figcraft_pathops::stroke_outline_of`, new `points.rs`). This applies to the canvas, PNG and PDF,
  and to SVG as outlines. Outline stroke follows it, render bounds count arrowheads from points, and
  the stroke cache key includes the points' styles.
- **Pen outside edit mode.** Clicking one of the path's own points (other than the first or last)
  connects there and ends the path, as a vector network. With an open vector selected, clicking one
  of its end points continues that path in edit mode.
- MCP: headless `key` Enter on shapes now enters vector edit mode, as in the desktop app. The new
  commands have Spanish labels, and the new UI string has en and es entries.
- Docs: target-app-parity (area 4: 98%/72% to 99%/73%, 2-4 h to 1-3 h; area 20: 80%/55% to
  88%/60%, 2-5 h to 1-3 h), ROADMAP, gaps (gap 9 is down to tablet pressure on macOS and Linux; the
  gap 13 note is updated), ui-parity and mcp.md. Totals are recomputed by the doc's method, because
  rows added since the last recompute had left them stale. Feature breadth is now ~83% and ready for
  real work ~52%. Mainstream stays at ~49% (49.7%, rounded down). Full parity goes from
  ~306-595 h to ~294-572 h.
2026-10-12 02:52:18 +07:00
bflatastic 7e467c2b82 M7: dev resources links
- Dev resources: links for developers (a URL and a name) on any layer, stored on the layer (`Node::dev_resources`, a new synced property `devResources` at the end of the property list), saved in the file and undoable.
- Commands: `devResource.add {id?, url, name?}`, `devResource.edit {id?, index, url?, name?}`, `devResource.remove {id?, index?}`, `devResource.list {id?}` (Spanish labels added).
- An instance lists its own links, then its main component's and that component's set's, read-only with `from`; layers inside an instance show their main layer's and take none of their own (refused with a hint). Instances don't copy the main's links (created empty, own links kept across main edits).
- URLs are data: http or https with a host, no whitespace or control characters, at most 2,048 characters; names clipped to 200; at most 32 per layer. Nothing is fetched: a link added without a name is named after its URL (scheme, `www.` and trailing slash dropped). The app opens a link only when it is clicked.
- Inspect tab: a Dev resources section (after Annotations) lists the links with a link icon, name and URL (or "From <main>"), opens on click, edits name and link inline (pencil), removes (minus) and adds from a "Paste a link" field (plus). New strings in the en and es catalogs; links are announced as links to screen readers.
- Agents: `get_node` / `inspect_document` return `devResources` (own and inherited); docs/mcp.md and docs/control-protocol.md describe them.
- The editor extension stays out of scope: the MCP server already serves design data, code and links to agents and editors.
2026-10-12 02:30:25 +07:00
bflatastic 8a983f42ce M3: extended collections tail
- Variables a library adds to a collection this file extends are now a library update of their own. `library::pending` counts published variables of an extended collection's library collection that have no copy yet; `library.updates` lists them by name without an `id` (no copy yet); `library.acceptUpdates` brings them in, inheriting. Before, they only arrived with the library's next other update. The old special case in `accept_updates` is gone (the pending list now carries them).
- Extending an extended collection: a library collection that is itself an extended collection (a brand theme extending a core theme, published) can be extended in another file; values flow down the chain as each file accepts its library's updates, and every level's overrides stay. This already worked because a library's extended collection arrives as a plain copy; it is now checked end to end and documented, and the `sync_extensions` shortcut note says why local chains aren't followed (extending takes library collections only).
2026-10-12 02:30:25 +07:00
bflatastic c50bbd08bd M8: branch version history and finer list merges
- A branch keeps its own version history. On a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions; the main file's history is untouched. The Version history panel on a branch tab lists the branch's versions (its banner gives way to the version banner while one is viewed).
- Branch versions are saved in the `.figcraft` file with the branch (`branches/<id>/versions/index.json` plus one snapshot each, the same layout as the file's own history; images shared; damaged entries skipped and reported; the snapshot byte budget is shared across branches).
- Merging adds a version to the main file's history (`Merged "<name>"`, after an autosave of main when it had changed); the merged or archived branch keeps its own history and opens with it.
- Fills, strokes and effects merge item by item in `figcraft_collab::merge`: different items changed on each side, or items added at the end on one side (or the same ones on both), merge without a conflict; the same item changed both ways, an item removed on one side, or different items added on both sides still conflict as the whole list. Both sides' writes become the merged list, so merging and Update from main write the same result.
2026-10-12 02:30:25 +07:00
bflatastic 970eb001f1 Docs: recompute headline percentages from the area table
Feature breadth ~84% (core ~94%), weighted ready ~63%, core ready ~72%, mainstream ~50%
(weekly areas 72.3%): earlier rows had moved area scores without carrying them into the
totals. Hours unchanged until the next full re-measure.
2026-10-12 02:09:10 +07:00
bflatastic bf82f7ebf9 M4: text lists, hanging punctuation and decoration styles
The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour).

- **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's.
- **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font.
- **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap).
- **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG.
- `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab.
- Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing.
- REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them.
- Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es.
- Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths.
- `TextData::path` is boxed (`Option<Box<TextPath>>`) to keep `NodeKind` under clippy's enum size limit after the new fields.
2026-10-12 02:09:10 +07:00
bflatastic 1caf771e82 M8: comments and version history behaviour suite and fixes
A behaviour suite for comments and version history written from the public documentation
(`crates/engine/src/history_behaviour_tests.rs`, 15 tests, about 80 table rows plus stepwise checks),
each row with a one-line derivation; where the documentation is silent the row says so and pins
today's behaviour. 4 of the 15 tests failed on the old code. Fixes, all in shared engine code:

- **Live sessions: comment threads made in the session couldn't be used.** Their ids come from the
  creator's id space (e.g. 4294967297) and every thread command (`comment.reply`, `edit`, `react`,
  `resolve`, `move`, `delete`, `markRead`, `open`, `link`) refused them as "out of range".
  Comment ids are now parsed as ids, not small indexes.
- **Restoring a version lost newer comments.** Comments are a conversation about the file, kept
  apart from its design history: restoring now keeps the current comments; a comment on a layer
  the version doesn't have stays where it was (on the nearest surviving parent, or the canvas).
- **Restoring now keeps the replaced design in the history** as an autosave first, unless a
  version of it was just saved or restored (so work isn't only in the session's undo history).
  The checkpoint marker is now "none yet" for a freshly opened file and compares contents when
  normalising copied the document.
- **A titled autosave becomes a named version** (`version.rename` clears `auto`), so it is no longer
  dropped when autosaves make room.
- **Merging a branch adds to the main file's history:** the main file as it was (an autosave,
  when it changed since its last version), then `Merged “<branch>”`.
- `version.restore` refuses up front for a view-only live session (nothing goes into the history).

Pinned where the documentation is silent: pins go to the innermost frame under them (not to a
leaf layer); copies, duplicates and duplicated pages come without comments; replying reopens a
resolved thread; only authors delete their messages (no accounts); stepping past the last thread
stays on it; comments show on the canvas by default; versions stay with whoever saved them in a
live session.

`crates/engine/src/collab_live_tests.rs`: the test `Room` is `pub(crate)` so the suite can run a
live session. `crates/engine/src/cmd/props.rs`: one line reformatted by `cargo fmt` (it was
unformatted on the base, which failed the fmt check). `crates/render/src/effects_behaviour_tests.rs`:
the paint suite expected an offset drop shadow to grow the export on every side, but the later export
suite made exports grow only where the shadow falls (40 x 50 for a shadow 10 down); the base failed
the gate on it, so the expectation now says "grows" (wider or taller). No new commands.

Docs: collaboration-parity, target-app-parity (area 16 85%/60% -> 85%/62%, 2-5 h -> 2-4 h; full
parity ~306-595 h -> ~306-594 h; percentages unchanged at this rounding), ROADMAP (also corrects the
stale area 16 row, 80%/58%/4-8 h), gaps (gap 4's landed list), mcp.md.
2026-10-12 02:09:10 +07:00
bflatastic f2c22213d6 M1: canvas navigation, guides and grids behaviour suite and fixes
A behaviour suite for canvas navigation, rulers, guides, layout grids, outline mode and measuring
(`crates/engine/src/view_behaviour_tests.rs`, 18 tests, ~120 table rows and checks), written from
the public documentation with a one-line derivation per case; where the documentation is silent the
case says so and pins today's behaviour. Plus the fixes it found:

- **Pixel grid** shows from 400% (it waited for 800%). The threshold, pixel preview's resolution
  and wheel panning (`ViewState::pixel_grid_shown`, `pixel_preview_scale`, `scroll`) now live in the
  engine; the canvas calls them.
- **Guides belong to the page or a top-level frame.** A guide dropped over a nested frame goes to
  the frame holding it (`guides::drop_owner`), `guide.add` refuses nested frames, and a frame moved
  into another frame keeps its guides but they stop drawing, snapping and measuring.
- **Selecting guides:** a click selects a guide (`guide.select`, new command, Spanish label added);
  Delete/Backspace removes it, the arrows across it nudge it (Shift: big nudge), Escape deselects;
  right-click opens the context menu with Remove guide (`guide.remove` without an index removes the
  selected guide). A click without a move no longer records an empty undo step.
- **Alt-drag** on a guide drops a copy; **holding Alt while dragging** a guide shows its distances
  to the nearest edges inside the frame it crosses (`distance::from_guide`).
- **Layout grids:** columns/rows with a fixed width and count 0 are **Auto** (as many as fit; the
  Count field shows Auto); grids don't show or snap on a **rotated frame**.
- **Outline mode:** shapes are hit on their outlines, so a click reaches layers drawn behind
  (`geometry::hit_outlines`).
2026-10-12 01:32:04 +07:00
bflatastic 8b2607de8e M7: export and inspect behaviour suite and fixes
A behaviour suite for export and inspect, written from public documentation of design tools'
export settings and developer handoff modes (`crates/engine/src/export_behaviour_tests.rs`,
`crates/engine/src/inspect_behaviour_tests.rs`: 19 tests, 139 table rows and asserts, each with a
one-line derivation; rows where the docs are silent say so and pin today's behaviour). 47 cases
failed on `main`; this PR fixes them in shared code.

**Fixes**
- Export bounds are what a layer paints (`Document::world_render_bounds`, used by every export):
  a drop shadow adds room only in the direction it falls (it was a symmetric margin of blur +
  largest offset on every side); strokes on rectangles, frames and ellipses reach their real width
  (the mitre factor made a centre stroke of 4 add 8 a side); hidden children take no room; a mask
  group exports the mask's area.
- Slices export everything visible in their area (they exported an empty image).
- Slashes in layer names export into folders (`icons/close` → `icons/close.png`, folders created;
  `.` and `..` segments dropped).
- Copy as PNG copies the whole selection (only the first layer was copied), now from shared code
  (`cmd::file::copy_png`).
- Inspect positions are measured from the frame around a layer; groups aren't frames.

**Added (documented options that were missing)**
- Export settings: contents only (off draws every layer showing in the area) and include bounding
  box (the layer's own box); SVG: outline text (off writes `<text>` with a `<tspan>` per line),
  id attributes (unique), simplified strokes (stroke attributes for centred strokes, and for inside
  and outside strokes on boxes and ellipses). Stored per export setting, taken by `file.export`,
  `export.add` / `export.update`, `file.exportLayers` and MCP `export`; a "…" popover on each row of
  the Export section.
- Inspect: colour formats hex / RGB / CSS / HSL / HSB (`inspect.properties {colorFormat}`, a Color
  format dropdown), a Blend row, CSS in rem (`inspect.code {unit, rootFontSize}`, px/rem dropdown),
  CSS for rotation, blend modes, gradient directions and kinds (linear angle, radial, conic), text
  alignment, case and decoration; icons and images in the asset list (`kind`), exported with
  `file.exportLayers {assets: true}`.
2026-10-12 01:32:04 +07:00
bflatastic df6bc0a319 M6: .fig import: editable vector networks and more layer kinds
The `.fig` import's tail (gap 1):

- **Editable vector networks.** Vector layers now open as vector networks instead of their outlines: points, curved segments with their tangents, filled areas (loops) with their winding rule, per-point corner radius and handle mirroring, and end caps taken from the end points' own styles (an arrow drawn on one end keeps its arrowhead on that end only). The network blob is decoded defensively (`crates/format/src/fig/network.rs`: bounds-checked reads, capped counts, indices checked, floats finite) and scaled from its normalized size to the layer's size. When the blob is missing or damaged, or an open path shows a fill that regions can't hold, the outline import is used as before (damage is reported in the warnings).
- **Layer kinds that used to be dropped** now import as the closest editable layers, each kind with one warning: sticky notes, shapes with text, code blocks, tables and table cells, stamps, media, widgets and slide containers become frames with their paints and children, their text added as a text layer (notes top-left, shapes centred); a non-rectangular shape with text gets its saved outline as a vector under the frame; connectors, highlights and washi tape become vectors (the network or outline when saved, else a straight line between the connector's ends: free points or a point of a sibling layer) with the connector's end caps; text on a path becomes plain text.
- **Variable prototype actions:** set-variable (literal, variable or expression values), set-variable-mode and conditional actions (if / else blocks with expression conditions) are mapped onto the REST importer's existing support. A condition that can't be read makes the whole conditional skip with a warning rather than turning into an "else".
- The REST importer gained two small extensions the `.fig` mapping writes: `vectorNetwork` (FigCraft's own network JSON, validated: sizes capped, coordinates finite and in range, references sanitized, else the outline with a warning) and per-end `strokeCapStart` / `strokeCapEnd`.
- Docs: gaps entry 1, file-format parity, target-app parity, ROADMAP and the MCP guide's `.fig` section.
2026-10-12 01:32:04 +07:00
bflatastic 2a54adb762 M4: shapes and vector behaviour suite and fixes
**A shapes and vector behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (drawing with Shift and Alt, corner radius and which layers take one, point counts and star ratios, arcs, stroke joins and the miter angle, caps per end, boolean results and the look they take, flatten and outline stroke; entering and leaving vector edit mode, the pen, selecting points and segments, deleting vs. delete and heal, handle mirroring, the bend, paint bucket, cut, join and eraser tools, nudging and undo steps, what converting a shape keeps) is a table row or a step with what the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour.

**Fixes**
- **Rounded vector points were stored but never drawn**: a point's corner radius (edit mode's Corner radius field, `vector.edit.cornerRadius`) now rounds the corner between two straight segments on the canvas, in hit testing, booleans and every export (`VectorNetwork::rounded`, used by the fill and stroke paths); the radius shrinks to fit half of each segment, like a polygon's corners. A vector layer's own corner radius rounds every point. The points stay where they are in edit mode.
- **A boolean group's look follows its operation**: union, intersect and exclude take the top layer's fills, strokes and effects, subtract the bottom layer's (it was always the bottom one).
- **Alt draws a line or an arrow from its centre**, like the other shapes (it was ignored).
- **Stars keep to 3–60 points** (they went to 120; polygons keep 120, the documentation is silent); a junk `pointCount` is refused instead of becoming 5.
- **Delete and heal recomputes the curve**: the healed segment keeps its neighbours' tangents and passes through where the deleted point was (it reused the neighbours' handles, so healing over a circle cut deep inside it). Corners between straight segments heal straight, as before.
- **Shift-click on a selected point at a corner of the points' transform box takes it out of the selection** (it started a scale); a Shift-drag there still scales in proportion.
- **Outline stroke** turns the stroke's colour style into the outline's fill style, and the outline keeps the layer's opacity, blend, effects, constraints and visibility (they were lost).
- **The stroke panel's Miter angle shows degrees** (it showed the ratio 4 under that name; the default is ~28.96°); `node.set {miterAngle}` sets it and `get_node` reports a non-default join or angle.
- **Individual corners only for rectangles and frames**: polygons, stars and vectors have one radius (the toggle showed for them and set corners that weren't drawn).
- **Option/Alt+Shift+F flattens** (Cmd/Ctrl+E still does).
- **MCP `key` in vector edit mode reaches the edit tools first**, as the canvas does: tool letters, arrows, Delete / Shift+Delete, Cmd/Ctrl+J, Enter (Delete removed the whole layer; `X` was unknown).

**Missing documented behaviour, added**
- **Corner radius and star ratio handles on the canvas**: a handle inside each corner of a selected rectangle or frame, one inside a polygon's or star's top point, a ratio handle on a star's first inner point (`shape_handles.rs`). Dragging along a corner's bisector sets the radius of every corner (Alt: only that corner of a rectangle or frame, documentation silent), stopping where the corners meet; the ratio handle sets the inner radius. A readout shows the value while dragging; the handles hide under 48 px on screen, on locked layers, with several layers selected and in edit mode. One undo step per drag; `inspect_ui` lists them under `tool.shapeHandles`.
- **Count and Ratio fields** in the Design panel for polygons and stars (there was no way to change them in the UI).
- **Cut tool drags**: dragging across paths cuts them where the line crosses; parts that come apart move to layers of their own, each keeping its piece of the fill (`vector.edit.cut {from, to}`). A click still splits at a point or segment.
- **Paint bucket drags**: a drag fills or unfills every area it crosses the way the first one went (`vector.edit.paint {points}`), one undo step.

New strings go through `tr(...)` (the new fields reuse the existing Count / Ratio / Radius / Star / Polygon entries in `en.toml` and `es.toml`); the new fields have accessible names (an icon lead with its label). Each gesture and edit stays one undo step.
2026-10-12 01:32:04 +07:00
bflatastic a2602071d7 M3: paint and effects behaviour suite and fixes
**A paint and effects behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (fill stacking and opacity, paint vs layer opacity, every blend mode per paint and per layer, pass-through and Normal containers, the four gradient kinds and their handles, stops, image scale modes, rotation and adjustments, the colour models and hex entry, the document colour profile, drop and inner shadows, showing shadows behind transparent areas, layer and background blurs, effects on groups, effect order, what exports include) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. Scenes are small and synthetic; pixel cases use tolerances (blurs are checked against the normal distribution with sigma = radius / 2).

**Fixes (13 render cases and 8 command/export cases fail on the old code)**
- **Pass-through and Normal containers**: a group or frame set to Normal now blends its children among themselves first (it never isolated), and a **clipping frame no longer isolates** its children: they were drawn on a fresh layer, so a Multiply child inside a frame (frames clip by default) met neither the frame's fill nor what is behind it. The clipped layer now starts as a copy of what lies under it and goes back in its place through the clip (a memcpy for rectangular clips, so no slower); backdrop effects see the right composite, also for a clipping frame inside a faded group. Same in exports: SVG gives Normal groups `isolation:isolate` and puts a clipping frame's clip on what blends instead of a clipped `<g>` (a clipped group is a stacking context, which isolates); PDF wraps Normal groups in an isolated group (`Node::isolates_children`, shared).
- **Plus darker** (linear burn) is drawn by its formula on the canvas and in pixel exports for paints, layers, shadows and noise (it was multiply); tiny-skia lacks the mode, so `fx::plus_darker` composites it and `paint::composite` routes every layer, shadow and noise composite through it.
- **Shadows hidden behind the layer**: a drop shadow that doesn't show behind transparent areas is now hidden behind what the layer covers, translucent and 0% fills included (it was cut out by the layer's alpha, so it showed through a 50% fill anyway); groups and layers whose pixels a layer blur or texture moves still hide it behind their pixels. One `shadow::Caster::new` serves the canvas, PNG and the PDF's shadow rasters. **SVG** never cut shadows out: its filters now composite them `out` of an image of the layer's outline (or `SourceAlpha` for groups), and the filter region reaches as far as the effects (a fixed 200% box cut off a small layer's long shadow).
- **Fill images cover the box to its edges**: an upscaled Fill (or covering Crop) image faded to transparent over a source pixel at the box's edge.
- **"Normal" meant pass-through** when named: `BlendMode::from_name("normal")` found pass-through first (its CSS keyword is also `normal`), so `node.set {blend: "normal"}` and the blend menu's Normal set pass-through. Labels now win, and the menu sends labels.

**Added**
- A blend mode menu for each paint in the colour picker (no pass-through for paints), sharing `right::blend_menu` with the Design panel's layer menu; strings already translated.
- Inspect (`node.get`, MCP) reports a solid paint's `blend` when it isn't normal.
2026-10-12 01:32:04 +07:00
bflatastic e917d3c2e6 M1: frames, groups, sections and masks behaviour suite and fixes
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour.

**Fixes (the suite failed on 7 tests before them)**
- **Masked-out areas don't take clicks**: hit testing and Select layer skip what a visible mask hides (the nearest mask below a layer; for a group or frame used as a mask, what its layers draw), so a click there reaches what's behind. Checked only once something is hit, and skipped in files without a mask (`Document::has_masks`, worked out once after each change), so hit testing costs what it did.
- **A mask group's box and exports are the mask's**: a group's bounds and export area count a mask for the layers it masks (layers under the mask still count), so the selection box, W/H, snapping and PNG/SVG/PDF exports crop to the mask instead of the whole masked layer (`Document::box_children`).
- **Sections stay on the page or in sections**: moving a section into a frame or group (`node.reparent`, refused at the root in `Document::reparent`), dragging one over a frame (it lands in the section around the frame, or on the page), grouping or framing a section, creating a section in a frame and pasting one into a selected frame (it goes on the page) no longer put sections where they can't be.
- **Drawing a frame or section around layers puts them in it**: the layers fully inside the drawn box (its siblings; visible and unlocked; a frame never takes a section; not in auto layout) move into it, in their order, without moving on the page; one undo step.
- **Resizing a section leaves its contents** where they are on the page (no constraints in sections), from a field, a handle (also the left/top edges) or `transform.resize` with any anchor.
- **Cmd/Ctrl while resizing a frame leaves its layers as they are**: #133 (merged in) keeps their places; this branch also keeps their sizes (the frame is resized without running constraints, so a left & right or scale child no longer stretches and is only moved back).
- **X/Y are measured in the frame, not the group**: groups and boolean groups have no coordinate space of their own, so a layer in a group shows (Design panel, `node.get`, `inspect_document`) and takes (`node.set`, `node.create` with a group parent) X/Y in the frame the group is in, or on the page (`Document::frame_relative`). Rotated groups too.

Already there and now covered by the suite: constraints when a frame resizes, groups scaling their layers, clip content (drawing, hits, export size), Frame selection's size and place, grouping/ungrouping keeping world transforms (rotated groups too), nested groups, empty groups going away, group opacity on the composite, click / Cmd-click / double-click targets (frames in sections count as top-level frames), dropping by the pointer and pulling out, group layers staying in their group when dragged, top-level frames as prototype screens and export boards, Use as mask (bottom layer, "Mask group", at the topmost layer's place, single-layer toggle), masks covering every layer above them, hidden masks, alpha / vector / luminance masks, a group as a mask, masks in SVG and PDF exports.

No new commands or UI strings (no i18n changes); each edit stays one undo step.
2026-10-12 00:57:43 +07:00
bflatastic f3012ce6e4 M3: styles and variables behaviour suite and fixes
**A styles and variables behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (what a style holds, creating from a selection, applying, editing, detaching, deleting a used style, organising by folders and order, descriptions; variable types, modes and the default mode, aliases, scopes, mode inheritance, bindings by field and type, deleting a bound variable, code syntax, number variables in pixels) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins the behaviour.

**Fixes (10 of the suite's 18 tests failed on the old code, counting the missing settings below)**
- **Editing a styled property unlinks that style**: a fill, stroke, effect or layout grid list edited away from its style (through `node.set`, a paint row, an added fill, or a variable bound to it) drops the link, as text chars already did. Before, the layer kept a stale link and the next style edit silently overwrote the user's edit. Moving or renaming a layer keeps its links.
- **Applying a style replaces variables bound to what it sets** (the fill list for a paint style, the typography for a text style); other bindings stay. Before, the variable won again on the next edit.
- **Text styles go on text layers only**: applying one to a mixed selection leaves the other layers alone (they used to get a stray text style link); a selection without text is refused.
- **Deleting a bound variable unbinds the layers using it**: they keep the value they show; variables aliasing it take its values per mode (docs silent on aliases). Before, the binding dangled. `variable.deleteCollection` now does the same for aliases from other collections into it.
- **Bindings are type-checked**: a variable of a type the field doesn't take (a colour on `width`, a number on `visible`) is refused; scopes still only filter pickers. Number variables can fill text content (shown as text).
- **Aliases are checked when a variable is created** (`variable.create {value: {alias}}` took any name, of any type).
- **Style names** are trimmed per `/` folder part; empty names are refused.

**Missing documented settings, added**
- **Organising styles**: `style.move {styles, before?, folder?}` reorders and moves into a folder (renaming the style to match), `style.duplicate` puts "<name> copy" right below the original, `style.renameFolder` renames a folder's styles. The page's **Local styles** list (nothing selected) is now grouped by type and folder with swatches and text sizes: click opens the style editor, right-click offers Edit style / Duplicate / Delete, a row dragged by its grip moves (into the folder it lands in), right-click on a folder renames it.
- **Modes**: Duplicate mode (`variable.addMode {from}`), Set as default and Move column left/right (`variable.moveMode {collection, mode, to}`); layers on Auto follow a new default, layers set to a mode keep theirs.
- **Code syntax** per platform (Web, iOS, Android): `variable.update {codeSyntax}`, three fields in Edit variable, carried by the variables JSON (import and export); generated CSS and the CSS variables export use the Web name (`var(--x)` or `--x`).
- **Number variable scopes**: font weight, paragraph spacing, paragraph indent and text content (and `fontWeight`, `paragraphSpacing`, `paragraphIndent` bind with their scopes).

New strings go through `tr(...)` with `en.toml` / `es.toml` entries, new commands have Spanish labels; the new rows, fields and menus have accessible names. Each edit stays one undo step.
2026-10-12 00:57:43 +07:00
bflatastic b65cc2e4fe M5: prototyping behaviour suite and fixes
**A prototyping behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule on triggers, actions, overlays, animations, easing, scrolling and interactive components is a case with what the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. The model rules are table-driven tests in the engine; the player's are driven through egui headless (real pointer, key and timer input into the presenter) or on the player directly.

**Fixes (the suite failed on these before them)**
- **On drag follows its animation**: with a move, push or slide animation, only a drag that way starts it (a drag another way can scroll instead), and the animation follows the pointer while the button stays down, then plays on from there when it lets go (it played by time, and any drag direction fired it).
- **Key combinations**: a key trigger can be a combination (`Shift+K`, `Ctrl+Alt+Delete`, other spellings of the modifiers accepted); the modifiers held must match, so `K` no longer fires for Shift+K. The interaction editor's recorder records the modifiers held, and REST files' modifier key codes import as the combination (they imported as the key alone, with a warning).
- **Overlay timers**: an overlay's after-delay timer starts when the overlay opens, and again each time it opens (it was timed from when the screen under it arrived, so a toast opened late closed at once, and it fired only the first time).
- **Interactive components**: every instance plays the interactions on the variant it shows; an interaction on the instance with the same trigger wins. Before, instances played only interactions copied onto them when placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown (`proto_run::play_interactions`, on the player's copy).
- **Change to on nested instances** changes the instance whose variant set holds the destination (`proto_run::change_to_target`), not the innermost instance (an icon inside a button).
- **Springs**: the presets are physical springs (mass 1; stiffness / damping gentle 100/15, quick 300/20, bouncy 600/15, slow 80/20; they were ad-hoc curves), and every spring plays until it settles instead of being squeezed into the duration field's time; the settling time accounts for over-damped springs. The interaction editor shows the spring's duration in place of the duration field, with a tooltip.
- **Smart animate overshoot**: matched layers follow spring and back easings past their end and back (they snapped to the end once the curve passed 1).
- **Fixed layers on top**: fixed layers draw and take clicks above the content scrolling under them, wherever they are in the layer list (the file's order doesn't change).

**Missing documented setting, added**
- **Scroll to offset**: `{type: "scrollTo", dest, offset: [x, y]}` stops the layer that far from the top-left of the innermost frame scrolling it (room for a fixed header); X / Y fields in the interaction editor; files carry it only when set.
- Already there and now covered by the suite: click / mouse down / mouse up / while pressing / while hovering / mouse enter / mouse leave / after delay, nested hotspot priority and click-through, navigate (closing overlays), back with the reversed animation, restart, overlay stacking / swap / close / close on a click outside, change to with smart animate, reversed directional transitions, easing curves.

New strings go through `tr(...)` with `en.toml` / `es.toml` entries. No new command ids. The player still draws nothing while idle: the drag-follow only runs during an animation, and fixed layers and instance interactions are worked out once per layout, not per frame.
2026-10-12 00:57:43 +07:00
bflatastic 62db8d4e36 M3: components behaviour suite and fixes
**A components and variants behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule of instances (what an instance overrides and what it can't, how overrides persist when the main changes, reset), variants (which overrides survive a switch, the default variant, property order, duplicate combinations), swaps (instance menu vs Assets panel), exposed nested instances, detach / reset all / push changes / go to main, resizing and auto layout instances is a table row with the values the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour. Nested instance overrides stay covered by `nested_override_tests`, slots by `components_tests`, accepting library updates with overrides kept by `library_tests` / `components_scale_tests` (#91).

**Fixes (9 of the 10 new tests fail on the old code)**
- **Overrides that were silently lost**: auto layout **padding**, **gap** and **alignment** (three override groups, so a padding override still follows the main's gap), **layout grids**, **export settings** and **prototype interactions** on layers inside instances, and every **text property** (resizing, paragraph spacing and indent, lists, max lines, OpenType features) were reset by the next edit of the main. They are override groups now: kept when the main changes that property, carried by variant switches and swaps, pushed to the main, reset per group, and listed in the Overrides section. REST imports map the matching `overriddenFields`. An instance's own export settings and interactions stay its own, like its place (as before).
- **What an instance can't override is refused** (rolled back with a message, one place for every path: commands, MCP, gestures): moving, rotating, flipping, nudging or aligning a layer inside an instance (auto layout items included: their place is the layout's), resizing it (text boxes keep their main's bounds; text whose box follows its content still grows), changing its constraints or resizing (hug / fill / min / max / absolute), and an instance's auto layout direction or other auto layout settings (only padding, gap and alignment are its own). Before, these became `position` / `size` overrides or were lost on the next main edit. Resizing the instance itself still works and its layers follow the main's constraints. Existing `size` / `position` overrides in files are still read and kept.
- **Variant switches** keep an override only where the layer's name path matches *and* both variants had the same value for that property; text layers keep all their changes (by name and hierarchy). Before, every same-named override survived, even where the new variant differs (a hover background overridden back to the default one). The instance's own overrides follow the same rule. Undo brings the dropped overrides back.
- **Alt-drop swaps from the Assets panel** (`instance.swapAt`, `library.swapAt`) keep only text layers' overrides; the instance menu's swap (`instance.swap`) still keeps every override on same-named layers.
- **Default variant = the top-left one** (was the first in layer order): new instances of a set, the set's Assets thumbnail, generated code, `component.properties` defaults and the panel's default dropdown. Picking another default moves that variant to the top-left corner (one undo step).
- **Hidden exposed instances hide their properties** (`instance.exposed`).
- **Push changes from a nested layer** goes to the outer instance's main, never into the nested instance's own main; it reuses the override copy code, so every group above is pushed too.

**Added**
- **Variant property order**: `component.editProperty {name, index}` moves a variant property; every variant's name follows.
- **Duplicate combinations**: `component.properties` reports `conflicts` (variants sharing one combination of values), and the component panel warns until each has a unique one.
- **Design panel**: Position, Rotation, Constraints, Dimensions / resizing and grid cell fields are greyed out (with a tooltip) for layers inside an instance; the auto layout direction, wrap, strokes / canvas stacking and add / remove auto layout are greyed out or hidden for instances. New strings go through `tr(...)` with `en.toml` / `es.toml` entries; disabled controls are announced as disabled.
2026-10-12 00:57:43 +07:00
BFlat 5923e7dfcf M3: embedded image colour profiles (#136)
## What

**Images are colour managed from their own profile into the file's colour space.** Until now every placed image was taken as sRGB. Now an image is converted once, when it is decoded (cached by image hash, filters and file profile, as before), from the space it says it is in into the file's working space (sRGB or Display P3), so a wide-gamut or tagged photo looks right on the canvas and in exports.

- **Where the profile comes from**: the embedded ICC profile of PNG (`iCCP`), JPG (APP2), WebP (`ICCP`) and TIFF (ICC tag); a PNG without one uses its `sRGB` chunk, or its `gAMA` (+ `cHRM`) chunks: `gAMA` 1/2.2 with sRGB (or no) primaries means sRGB, which is what PNG writers put next to an `sRGB` chunk; any other gamma/primaries become a matrix/curve profile.
- **Conversion**: moxcms, a pure-Rust colour management crate the `image` crate already builds (now a direct dependency of `figcraft-render`, same version and features: nothing new compiles). It runs matrix/TRC profiles (v2 and v4: `curv` gamma and tables, `para` curves, chromatic adaptation), LUT-based RGB profiles and grey (`kTRC`) profiles, so sRGB, Display P3, Adobe RGB, ProPhoto and camera/phone profiles all work.
- **Fallback**: no profile, a profile that isn't RGB or grey (CMYK, Lab), a damaged one, one over 4 MB or one moxcms can't build a transform for: the image is taken as sRGB exactly as before (same pixels, same filter order).
- **Exports**: the original file stays in the document. PNG/JPG/WebP exports carry the converted pixels tagged with the file's profile (unchanged mechanism); PDF exports still embed the original file, with its ICC profile; SVG exports embed the original file.
- **UI**: the image fill's details in the paint picker show *Color profile: Adobe RGB (1998)* (the profile's own name), or *Unknown — treated as sRGB*; strings through `tr()` with `en.toml` / `es.toml` entries. No new command ids.
- **Agents**: `image.place` / `file.placeImage` report `imageProfile` (the name, or `null` when taken as sRGB); `docs/mcp.md` updated.

Untrusted input: profiles over 4 MB are ignored before parsing, moxcms's own caps on curve and LUT sizes apply, colorants must be finite and a transform must build before a profile is used; anything else falls back to sRGB without panicking. The parser is swept with 600 mutated profiles (byte flips, truncation, absurd tag counts and offsets) through the whole decode path.

## Verified
- **Desktop app** over the control channel (own target dir, separate `APPDATA`, stopped by PID): placed a synthetic 200×200 PNG with an embedded Adobe RGB (1998) profile built by a script (v2, `curv` gamma 563/256, published D50 colorants) next to the same pixels without a profile; the screenshot shows the tagged one more saturated (as it should be: its values mean wider-gamut colours), the untagged one unchanged. The paint picker reads *Color profile: Adobe RGB (1998)* for the first and *Color profile: Unknown — treated as sRGB* for the second; `image.place` returned `"imageProfile": "Adobe RGB (1998)"` and `null`.
- **MCP**: `figcraft-cli mcp` (headless) placed both images, exported each layer to PNG in an sRGB file and in a Display P3 file, and read the pixels back: the tagged image's four patches match the published Adobe RGB → XYZ → sRGB / Display P3 matrices exactly (sRGB: (0,201,67), (230,79,56), (0,90,174), (129,129,129); P3: (0,197,84), (213,90,67), (14,88,168), (129,129,129)); the untagged image exports its original values in the sRGB file and the usual sRGB → P3 values in the P3 file; the P3 exports carry the ICC profile.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm).

## Tests
- `render` (`image_profile_tests`, 9 tests, every ICC profile built in code from published constants; no profile files): Adobe RGB PNG into sRGB and Display P3 against the published matrices (alpha kept, untagged pixels untouched); the profile read from PNG, JPG, WebP and TIFF; `curv` table and `para` curves; a Display P3 profile staying put in a P3 file and matching the file conversion in an sRGB one; grey `kTRC`; CMYK, junk, truncated and oversized profiles falling back to sRGB; PNG `sRGB` / `gAMA` / `cHRM` (sRGB-equivalent, linear, Adobe primaries, nonsense values); profile names cached and cleaned of control characters; the junk sweep.

## Not in this PR
- PNG `cICP` without `iCCP`, and GIF/BMP profiles, are ignored (taken as sRGB); FigCraft's own P3 PNG exports carry `iCCP` too, so they re-import correctly.
- The `image` crate drops the ICC tag of very small TIFFs (16×16 lost it, 64×64 kept it in testing); real photos are far larger.
- A PNG that has only `gAMA`/`cHRM` (no ICC) is embedded as is in PDF exports, where viewers take it as sRGB; the canvas and pixel exports convert it.
- Image adjustments on a profiled image apply to the converted values (on an untagged image they still apply before the sRGB → P3 conversion, as before).
- Progress docs: area 5 (Paint) 87%/66% → 90%/67%, 4–7 h → 3–6 h; feature breadth, ready for real work and full parity hours unchanged at this rounding (target-app-parity); PNG, JPG, WebP and TIFF rows in file-format-parity.
2026-10-12 00:11:43 +07:00
BFlat cc30a261e7 M1: layers panel keyboard (box selection, F2 rename, context menu) and stroke styles (#135)
## What

**Layers panel from the keyboard** (two of the three open items of area 3, plus long-list QA)
- **F2 renames** the selected layer (a second key for `object.rename`, next to Cmd/Ctrl+R) from the canvas, after a click on a row, or from a layer row that has the keyboard (F6 mode, where the canvas shortcuts are off, so the layers branch of `a11y::keys` runs it). When the rename ends the row gets the keyboard back (`a11y::refocus_row`); Escape in the field cancels and stays in the panel instead of jumping to the canvas.
- **Shift+F10 opens the context menu** (`view.contextMenu`, a new command that only asks the UI): at the focused layer row when the layers panel has the keyboard (F6 or a clicked row), else on the canvas just below the selection (the middle of the canvas with nothing selected). The menu takes the keyboard like the other menus opened from the keyboard (`a11y::popup`), and Escape gives it back to the row or the canvas. The Menu key can't be supported: egui 0.36 has no key for it.
- **Keyboard box selection** (`select.area`, F8): for keyboard-only users, a selection box appears around the selection (or in the middle of the view); the arrows move it, Shift+arrows move its far corner (10 screen pixels a press), Enter selects what it covers exactly like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter takes nested layers), Escape or a click drops it. A toast and the canvas's screen-reader description say the box's size and the keys. With a box (`{x, y, width, height, add?, deep?}`) the command selects directly, for agents. The marquee's rules (top-level layers, frames only when covered, the children of the frame the box starts in, the focus view, Shift toggling) moved into `Session::marquee_within` / `marquee_selection`, shared by the pointer marquee and the command.
- **Long lists (10k layers)**: a test that a layer near the bottom of a 10k-layer list is revealed (parents expanded, list scrolled) and that F6 lands the keyboard on its row and the arrows walk on from it; the UI bench (`perf_ui_frames`) gained rows for expanding every layer, arrow keys down the expanded list, revealing a layer at the bottom and collapsing everything. Release, 10k layers: first frame with every layer expanded 1.22 ms, an arrow key 1.04 ms, reveal 0.30 ms, collapse all 0.37 ms, idle 0.17 ms.

**Stroke styles** (area 6)
- What the public documentation describes for strokes was mostly already in: colour styles apply to strokes from the stroke section's style picker (`style.apply {target: "stroke"}`, linked, following `style.update`, one undo step, `style.detach {which: "stroke"}` keeping the colour), and the stroke weight binds to number variables. A test now covers that path.
- Missing piece, added: **each side's stroke weight binds to a number variable on its own** (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`): `node.set` keys (setting or binding one side turns on individual strokes with the other sides at the weight), the stroke-weight variable scope, the stroke override group for instances, and REST `boundVariables` import. The Design panel's per-side fields are bindable, two to a row like the corner radii so a variable's name fits.

New strings go through `tr(...)` with `en.toml` / `es.toml` entries, and the two new commands have Spanish labels. Shortcuts are only in `keymap.rs`. Nothing redraws while idle (the box draws only when a key moves it). Selecting from the box leaves no undo step; each stroke edit is one.

## Verified
- **Desktop app** over the control channel (`target/debug/figcraft.exe` from this worktree's target dir, `--control <free port>`, separate `APPDATA`, keys sent as real `ui.input` key events, stopped by PID): F2 on a selected layer opens its name field in the layers panel (screenshot), typing and Enter rename it; F6 to the layers panel, Shift+F10 opens the menu at the Avatar row with Copy focused (screenshot), Escape gives the keyboard back to the row, F2 + typing + Enter renames it and the row keeps the keyboard ("Profile"); Shift+F10 on the canvas opens the menu for the selected frame with Copy focused (screenshot); F8 shows a box around the selected frame, Shift+arrows stretch it over a second layer (screenshot with the hint toast), Enter selects both; a rectangle with the colour style "Brand/Accent" on its stroke and its top weight bound to a number variable (screenshot: the style chip in the stroke section, the red stroke thicker at the top, the per-side field showing the variable).
- **MCP**: `figcraft-cli mcp` (headless): created a paint style, `style.apply {target: "stroke"}`, `object.rename`, bound `strokeLeftWeight` to a variable and ran `select.area` over the layer; `inspect_document` shows the name "Outlined card", `styles.stroke: "S:1"` with the style's colour on the strokes, `strokeSides [2, 2, 2, 6]`, `boundVariables {strokeLeftWeight}`, and one undo step per command.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: release UI bench at 10k layers (numbers above).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.

## Tests
- `ui-egui` `layers_keyboard_tests` (3): F2 from the canvas and from a focused row (the row gets the keyboard back, Escape cancels without leaving the panel); Shift+F10 at a focused row, on the canvas below the selection and after a clicked row, the menu taking and giving back the keyboard; F8: the box around the selection, the screen-reader description, Shift+arrows sizing, Enter selecting, arrows moving it without nudging layers, Escape, the middle of the view with nothing selected, a click dropping it.
- `ui-egui` `perf_tests::a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row`, plus the new bench rows.
- `engine` `select_area_tests` (2): marquee rules through the command (negative sizes, frames, starting inside a frame, deep, add toggling, empty spots), the UI request with no box, bad boxes refused (the junk sweep covers both new commands too).
- `engine` `stroke_style_tests` (2): a colour style on strokes (link, undo, style edits, detach); stroke weight and per-side weights bound to variables following the frame's mode, `node.set` per-side keys, scopes and override groups.

## Not in this PR
- The Menu (application) key: egui 0.36 doesn't deliver it.
- Enter doesn't rename: on the canvas and a focused row it already selects children / activates the row.
- The keyboard box doesn't pan the view when it is moved off screen (zoom and pan keys still work while it shows).
- Shift+F10 does nothing while a toolbar or properties control has the keyboard.
- Progress docs: area 3 95%/78% → 98%/82%, 2–4 h → 1–2 h; area 6 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; percentages unchanged at this rounding; full parity ~323–621 h → ~322–618 h (target-app-parity, ROADMAP); ui-parity rows (context menus, keyboard box selection, layers from the keyboard); `docs/mcp.md` documents `object.rename` without a name, `view.contextMenu`, `select.area` and stroke styles and variables.
2026-10-11 23:10:41 +07:00
BFlat f665765128 M4: text behaviour suite: metrics, line height, CJK (#134)
## What

**A text behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (Auto / % / px line height and how the leading is split, letter spacing in % and px, paragraph spacing and indent, auto width / auto height / fixed / truncate with max lines, vertical trim, decorations from the font's metrics) is a table-driven case with the numbers the rule implies and a one-line derivation, read from the face actually used so it holds on any machine with a font. CJK line breaking follows UAX #14 and the CJK line-start / line-end rules; caret steps follow UAX #29 grapheme clusters. Where the documentation is silent the case says so and pins today's behaviour.

**Fixes (6 of the 13 text cases fail on the old layout; the break and caret rules are new functions)**
- **CJK line breaking**: lines break between ideographs, kana and Hangul and before or after a Latin word next to them (before: no break before an ideograph that follows Latin, none after `。`, full-width forms, CJK punctuation or Extension B ideographs), never inside a Latin word, with kinsoku: closing brackets and quotes, `、。,.!?`, small kana, the prolonged sound mark and iteration marks never start a line (the character before moves down with them), opening brackets never end one. The ideographic space hangs at the line's end like a space. One small break-class table in `crates/text/src/breaks.rs`, no new crate.
- **Fallback fonts no longer move the baseline**: a style's ascent, descent, line height and cap height come from its own font, not the fallback face that drew some of its characters, so a line with CJK or emoji keeps the height and baseline of a Latin line (with a fixed line height the mixed line's baseline used to shift by the CJK face's taller metrics; with Auto the line grew). Decorations likewise use the style's font and size, so an underline runs at one height and weight through mixed scripts (and isn't thinned by synthesised small caps).
- **Paragraph indent applies to left-aligned text only** (documented); right-to-left paragraphs keep it when right-aligned (their start). The Type settings field is disabled with a hint otherwise.
- **Carets by grapheme cluster**: Left / Right, Delete and Backspace step over a whole emoji ZWJ sequence, flag, skin-tone sequence or letter with combining marks (they moved one code point at a time, leaving the caret inside an emoji and deleting half of it). A cluster is also shaped with one font, so a joiner or variation selector no longer splits an emoji sequence across faces.
- **Words by script**: double-click and word moves (Alt on macOS, Ctrl on Windows and Linux) take one script's run in text without spaces (`日本語` | `の` | `テキスト`), Hangul words end at spaces, and a Latin word keeps its combining marks (before: a whole CJK sentence was one word, and `cafe` + combining acute split at the accent).

**Missing documented setting, added**
- **Vertical trim** (cap height to baseline): `leadingTrim: none | capHeight` (`CAP_HEIGHT` from REST JSON) through `node.set`, a Standard / cap-height segmented control in Type settings, `inspect_document` and Dev Mode properties, CSS `text-box: trim-both cap alphabetic` in generated code. The box runs from the first line's cap height to the last baseline (auto height shrinks, auto layout parents follow); truncation counts the trimmed bottom; exports reach past the box so descenders below the last baseline aren't clipped. Canvas, PNG, SVG and PDF all draw from the same layout.

New strings go through `tr(...)` with `en.toml` / `es.toml` entries; the new segmented control reuses the Type settings one (accessible toggle names). Each edit is one undo step. `unicode-segmentation` (already in `Cargo.lock` through egui and winit) became a direct dependency of `figcraft-text`.

## Verified
- **Desktop app** over the control channel (own target dir, separate `APPDATA`, stopped by PID): a 200 px auto-height box of Japanese wraps with no line starting with `、。」` and `「` never ending a line (`…テスト` / `を折り返す、テスト` / `です。「括弧」は行` / `末に来ません。漢` / `字とかなとカタカ` / `ナ。`, the last `ナ` carried down with `。`); a Latin / CJK / Latin paragraph at 36 px line height is 108 px with evenly spaced baselines; the same text with vertical trim is 89.45 px (cap height 17.45 + 2 × 36), its box running from the cap height to the last baseline, and the Type settings control turns it off (108 px) and undo brings it back in one step; one Right arrow from after `a` in `a👨‍👩‍👧b` lands after the whole family emoji and Shift+Right then selects `b`; a double click selects the katakana run テキスト.
- **MCP**: `figcraft-cli mcp` (headless) built the same three texts; `inspect_document` reports 108 px for the 36 px paragraph and 89.42 px with `leadingTrim: capHeight` for the trimmed one (cap height of the headless font), the PNG export wraps the Japanese by the same rules and keeps the trimmed text's descenders, and `inspect.code` emits `text-box: trim-both cap alphabetic`.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: text layout of a 2,250-char Latin and a 2,000-char Japanese paragraph wrapped at 300 px (release, median of 7 × 30 runs) before and after on the same machine: Latin 0.70 → 0.42–0.50 ms, Japanese 0.46 → 0.37–0.47 ms (within noise or faster: line metrics are now computed once per style change instead of per glyph). All-ASCII text skips grapheme segmentation.
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.

## Tests
- `text` (`behaviour_tests`, 13 tests): line height Auto / % / px; leading split above and below every line (positive and negative); letter spacing % / px / negative; paragraph spacing and indent by alignment; resizing and truncation with max lines; vertical trim (heights, first baseline, glyph spacing kept, truncation); decorations from the font's metrics through mixed scripts; fallback fonts keep line height and baseline (Auto, px, %); 22 break-opportunity cases; CJK wrapping with kinsoku, Latin words in CJK and Korean; the ideographic space hanging; grapheme steps over ZWJ families, flags, combining marks, skin tones and emoji presentation; one face per cluster. Font-dependent cases skip on a machine without fonts.
- `engine` (`text_behaviour_tests`, 5): arrows, Shift+arrows, Delete and Backspace by cluster; double-click and word moves by script (and through `keymap::text_motion` for Ctrl and Alt); IME composition with CJK wrapping and growing the box before commit; vertical trim through `node.set` (box size, PNG ink at the top, export bounds below the box, SVG and PDF, CSS, inspect, undo, junk refused, REST spelling); paragraph indent only on left-aligned text.
- `format` REST import test covers `leadingTrim: CAP_HEIGHT`.

## Not in this PR
- Documented but not modelled yet: list spacing, hanging quotes and hanging lists, underline style / thickness / offset / skip ink.
- Documentation silent, behaviour pinned: Korean wraps by syllable (no word mode); Han compounds next to each other are one word (no dictionary segmentation); letter spacing is added after the last character too; a fallback font's taller glyphs may overflow the line rather than grow it.
- `.fig` import doesn't read vertical trim yet (the field wasn't checked against real files).
- Spell check (on hold, #113).
- Progress docs: target-app-parity area 10 85%/64% → 87%/68%, 7–13 h → 5–10 h with the suite listed under text editing depth; feature breadth and ready for real work unchanged at this rounding; full parity ~323–621 h → ~321–618 h (target-app-parity, ROADMAP); ui-parity text row.
2026-10-11 22:09:46 +07:00
609 changed files with 101064 additions and 5470 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Scripts and server files run on Linux: keep their line endings LF in every checkout and archive
# (a copy of a Windows checkout must still run packaging/server/install.sh).
*.sh text eol=lf
packaging/server/* text eol=lf
+2
View File
@@ -29,6 +29,7 @@ concurrency:
jobs:
test:
timeout-minutes: 35
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
@@ -69,6 +70,7 @@ jobs:
run: cargo run --locked -q -p xtask -- assets
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
+7 -4
View File
@@ -5,12 +5,14 @@
# uploaded and no secrets are used.
name: FreeBSD
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
@@ -41,8 +43,9 @@ jobs:
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
# dejavu: the text tests need one real font, as the Ubuntu runner has.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
+3
View File
@@ -19,6 +19,7 @@ concurrency:
jobs:
lint:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
@@ -35,6 +36,8 @@ jobs:
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Server install script (Caddyfile handling)
run: bash packaging/server/install-test.sh
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell
+10 -1
View File
@@ -41,6 +41,7 @@ env:
jobs:
version:
timeout-minutes: 10
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
@@ -69,6 +70,7 @@ jobs:
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
timeout-minutes: 90
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
@@ -127,6 +129,7 @@ jobs:
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
timeout-minutes: 90
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
@@ -187,6 +190,7 @@ jobs:
if-no-files-found: error
linux:
timeout-minutes: 90
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
@@ -252,6 +256,7 @@ jobs:
if-no-files-found: error
server:
timeout-minutes: 90
name: Server Linux ${{ matrix.arch }}
needs: version
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
@@ -284,7 +289,7 @@ jobs:
- name: Check the archive
run: |
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/server\.md$' '/LICENSE-MIT$'; do
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
done
ldd target/release/figcraft-server
@@ -295,6 +300,7 @@ jobs:
if-no-files-found: error
flatpak:
timeout-minutes: 90
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
@@ -388,6 +394,7 @@ jobs:
if-no-files-found: error
web:
timeout-minutes: 90
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
@@ -433,6 +440,7 @@ jobs:
if-no-files-found: error
linux-riscv64:
timeout-minutes: 90
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
@@ -502,6 +510,7 @@ jobs:
if-no-files-found: error
release:
timeout-minutes: 10
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
runs-on: ubuntu-latest
+6 -4
View File
@@ -4,15 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
# change the Windows packaging itself.
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "41 4 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * *"
workflow_dispatch:
concurrency:
+2 -1
View File
@@ -46,7 +46,8 @@ code.
`assets/`, `docs/brand/` or `docs/images/`.
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
are original. Keep it that way: new icons are new code, designed from scratch.
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`).
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`
+3 -2
View File
@@ -11,9 +11,10 @@ MIT OR Apache-2.0. No third-party icon sets are used.
| Path | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `assets/app-icon/` (all files: `figcraft-*.png`, `figcraft.ico`, `hicolor/*/apps/ai.storyteller.figcraft.png`) | FigCraft contributors | Generated by `cargo xtask icon` (`xtask/src/icon.rs`) with FigCraft's own renderer | MIT OR Apache-2.0 | Original geometric mark (stacked layer cards and a cursor) |
| `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) |
| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only |
| `docs/images/` (all screenshots) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft | MIT OR Apache-2.0 | No third-party artwork |
| `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork |
| `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork |
## Fonts (build input, not in this repository)
+2 -1
View File
@@ -46,7 +46,8 @@ code.
`assets/`, `docs/brand/` or `docs/images/`.
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
are original. Keep it that way: new icons are new code, designed from scratch.
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`).
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`
Generated
+52 -28
View File
@@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"arboard",
"eframe",
@@ -1418,10 +1418,12 @@ dependencies = [
"figcraft-format",
"figcraft-text",
"figcraft-ui-egui",
"fmv-macos-events",
"image",
"log",
"rfd",
"serde_json",
"wat",
"webbrowser",
"windows",
"winresource",
@@ -1429,7 +1431,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1443,7 +1445,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1458,7 +1460,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1481,7 +1483,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"kurbo",
"serde",
@@ -1490,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1502,7 +1504,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1519,6 +1521,7 @@ dependencies = [
"figcraft-text",
"image",
"log",
"regex",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -1528,9 +1531,10 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
"flate2",
"image",
"ruzstd",
@@ -1542,7 +1546,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"kurbo",
"serde",
@@ -1550,7 +1554,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1558,7 +1562,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1574,7 +1578,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1583,7 +1587,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1600,11 +1604,12 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-color",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
"wasmi",
"wat",
@@ -1612,7 +1617,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1622,13 +1627,15 @@ dependencies = [
"figcraft-text",
"image",
"log",
"moxcms",
"png",
"rayon",
"tiny-skia",
]
[[package]]
name = "figcraft-server"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1638,17 +1645,21 @@ dependencies = [
"figcraft-doc",
"figcraft-engine",
"figcraft-format",
"figcraft-plugin",
"log",
"rustix 1.1.5",
"serde",
"serde_json",
"sha2",
"toml",
"tungstenite",
"wat",
"zip",
]
[[package]]
name = "figcraft-svg"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1657,24 +1668,28 @@ dependencies = [
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"flate2",
"thiserror 2.0.21",
"usvg",
]
[[package]]
name = "figcraft-text"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"log",
"png",
"skrifa 0.47.0",
"unicode-bidi",
"unicode-segmentation",
]
[[package]]
name = "figcraft-ui-egui"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1682,6 +1697,7 @@ dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-engine",
"figcraft-format",
"figcraft-geom",
"figcraft-layout",
"figcraft-pathops",
@@ -1693,17 +1709,19 @@ dependencies = [
"serde_json",
"toml",
"wat",
"web-time",
]
[[package]]
name = "figcraft-web"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"eframe",
"egui",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-ui-egui",
"js-sys",
"log",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -1736,6 +1754,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -3912,9 +3941,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
[[package]]
name = "ryu"
@@ -5793,14 +5822,9 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.3.1"
version = "0.6.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-engine",
"figcraft-render",
"figcraft-svg",
"image",
"serde_json",
]
+7 -2
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.3.1"
version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -37,6 +37,7 @@ kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
proptest = "1"
imbl = { version = "7", features = ["serde"] }
@@ -45,16 +46,20 @@ postcard = { version = "1", default-features = false, features = ["alloc"] }
# rendering / formats
tiny-skia = "0.12"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
# Already built for `image`: embedded ICC profiles (moxcms) and PNG colour chunks (png).
moxcms = "0.8"
png = "0.18"
usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
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", default-features = false, features = ["std"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
+123 -5
View File
@@ -34,11 +34,20 @@
<br>
<p align="center">
<img src="docs/images/shot-1-hero.png" alt="FigCraft in its dark theme editing the Ridgeline hiking app: three phone screens (Home in light mode, a trail detail page and Home in dark mode) on the canvas, the Featured trail card instance selected, the layers panel showing the screen's auto layout tree, and the Design panel showing the instance's Fill and Hug auto layout sizing, its Theme variable mode and a fill bound to the surface colour variable" width="100%">
<br><sub><b>Ridgeline</b>: auto layout screens built from components, with Light and Dark modes from one set of colour variables · <code>examples/ridgeline-app.figcraft</code></sub>
</p>
<br>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Designers, illustrators, developers,
> musicians, game makers &mdash; if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#a-look-around">A look around</a> ·
<a href="#made-in-figcraft">Made in FigCraft</a> ·
<a href="#features">Features</a> ·
<a href="#built-for-agents">Agents</a> ·
<a href="#under-the-hood">Under the hood</a> ·
@@ -61,7 +70,7 @@
</td>
<td width="25%" valign="top">
<h3>🔌 Offline and private</h3>
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no telemetry, and nothing leaves your computer unless you share a file (<a href="#privacy-and-online-sharing">what sharing sends where</a>). SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
</td>
<td width="25%" valign="top">
<h3>🤖 Agent-ready</h3>
@@ -72,6 +81,86 @@
<br>
## A look around
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-2-components.png" alt="The Ridgeline UI design system open with the Assets panel on the left showing live thumbnails of the Button, Difficulty, Search field, Toggle and icon components; a Button component set of nine variants laid out in a three-by-three grid auto layout is selected, and the Design panel lists its Style (Primary, Tonal, Outline) and State (Default, Hover, Disabled) variant properties" width="100%">
<p align="center"><sub><b>Components and variants</b>: component sets, variant properties and the Assets panel · <code>examples/ridgeline-design-system.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-3-prototype.png" alt="Four onboarding phone screens in the Prototype tab, joined by blue connection curves from the Get started, Continue, Allow location and Not now buttons to the next screens; the Get started button is selected and the Prototype panel shows its On click, Navigate to 2 Interests interaction" width="100%">
<p align="center"><sub><b>Prototyping</b>: connections, transitions, back actions and a flow · <code>examples/ridgeline-onboarding.figcraft</code></sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-4-devmode.png" alt="Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows generated React code with a TrailRow props interface and the instance's title and details values" width="100%">
<p align="center"><sub><b>Dev Mode</b>: ready-for-dev status, annotations and React code where component properties become props · <code>examples/ridgeline-app.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-5-vector.png" alt="Vector edit mode on a wave path with a gradient, tapered variable-width stroke: its anchor points, Bezier handles and the transform box of the selected points are visible, with the vector edit toolbar at the bottom; above it are a boolean-built mountain mark and an angular-gradient knot of excluded rings" width="100%">
<p align="center"><sub><b>Vector networks</b>: points and handles, variable-width strokes, booleans that stay editable · <code>examples/glass-and-vectors.figcraft</code></sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-6-dashboard-light.png" alt="The light UI theme with a web analytics dashboard for a fictional trail operator: a dark sidebar, KPI cards with sparklines, a grouped bar chart, a donut chart, a table of top trails and an area chart, with the frame's 12-column layout grid showing as tinted columns and its fill linked to a paint style" width="100%">
<p align="center"><sub><b>Layout grids and styles, light theme</b>: a 12-column grid, paint, text, effect and grid styles, charts drawn from shapes · <code>examples/ridgeline-studio-dashboard.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-7-collab.png" alt="A live session seen from one participant's window: avatars of three people in the top right, a Priya Nair is spotlighting banner with a Follow button, Priya's green cursor and selection outline on the dark home screen's trail card, and Jonas Ortiz's purple cursor and selection on the Start hike button with his cursor chat bubble asking whether the button should stay pinned while scrolling" width="100%">
<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>
</table>
## Made in FigCraft
Every design below was built entirely through FigCraft's command API, the same one the MCP server
exposes to agents, and exported at 2x by FigCraft's own renderer. The source files are in
[`examples/`](examples).
<table>
<tr>
<td width="33%" valign="top">
<img src="docs/images/art-app-home.png" alt="Ridgeline home screen in light mode: a greeting, a search field, filter chips, a featured trail card with an illustrated mountain landscape, a list of nearby trails and a translucent tab bar" width="100%">
<p align="center"><sub><b>Home, light</b></sub></p>
</td>
<td width="33%" valign="top">
<img src="docs/images/art-app-trail.png" alt="Ridgeline trail detail screen: a dusk mountain illustration with glass buttons, a sheet with difficulty tags, the trail name, distance, elevation and duration stats, an elevation profile chart and a Start hike button" width="100%">
<p align="center"><sub><b>Trail detail</b></sub></p>
</td>
<td width="33%" valign="top">
<img src="docs/images/art-app-home-dark.png" alt="The same Ridgeline home screen in dark mode, its colours switched by the Theme variable collection's Dark mode" width="100%">
<p align="center"><sub><b>Home, dark</b>: the same frame in the Dark mode</sub></p>
</td>
</tr>
<tr>
<td colspan="3" valign="top">
<img src="docs/images/art-dashboard.png" alt="Ridgeline Studio dashboard: a dark navy sidebar with navigation, KPI cards for active hikers, check-ins, average rating and open incidents with sparklines, a hikes-per-day grouped bar chart with a tooltip, a difficulty donut chart, a top trails table with condition badges and a visitors area chart" width="100%">
<p align="center"><sub><b>Ridgeline Studio</b>: a desktop dashboard on a 12-column grid, every chart made of shapes and vectors</sub></p>
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/images/art-glass-weather.png" alt="A glass weather widget over blurred teal, violet, rose and orange colour fields: a frosted card for Lake Louise showing 14 degrees and partly cloudy with a gradient sun behind a boolean-union cloud, an hourly forecast row, and smaller glass cards for air quality, wind and humidity" width="100%">
<p align="center"><sub><b>Glass weather</b>: glass and background blur, layer-blurred colour fields, a boolean cloud</sub></p>
</td>
<td width="33%" valign="top">
<img src="docs/images/art-vector-marks.png" alt="Three tiles on a cream background: a gradient mountain mark made by unioning two peaks and subtracting a sun, a knot of three excluded rings with an angular gradient, and a tapered gradient wave with a leaf and a glowing four-point star" width="100%">
<p align="center"><sub><b>Vector marks</b>: union, subtract and exclude, an angular gradient, a variable-width stroke</sub></p>
</td>
</tr>
<tr>
<td colspan="3" valign="top">
<img src="docs/images/art-ds-colour.png" alt="Ridgeline UI colour ramps: six swatches each of Pine greens, Sunset oranges and Slate greys, each labelled with its name and hex value" width="100%">
<p align="center"><sub><b>Ridgeline UI</b>: colour ramps where every swatch is a paint style, exported through a slice</sub></p>
</td>
</tr>
</table>
## Features
<table>
@@ -102,7 +191,7 @@
</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, 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, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
@@ -138,10 +227,10 @@ 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 and Spanish (a draft waiting for a native speaker's review), 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)).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian 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 two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. 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).
- **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.
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
@@ -162,6 +251,10 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
git clone https://github.com/storytold/craft-fonts ../craft-fonts
```
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
@@ -171,6 +264,31 @@ Installers for macOS (universal, signed and notarized), Windows (x64, x86 and ar
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
FigCraft works offline: it has no account and no telemetry, and it contacts no server until you
share or join a file. Live sessions are a **preview**. Before you share anything that matters,
know what sharing does:
- **Share ▸ Online uploads the file to a server.** By default that is the public server at
`collab.craftapps.dev`; you can set another one, or your own, in Preferences (and a build can
ship without it: [`docs/releasing.md`](docs/releasing.md#the-default-server)). The file stays on
the server after you stop sharing, until you remove it (Share ▸ Advanced ▸ Remove from server…)
or nobody has opened it for 30 days, when a public server deletes it.
- **It is not end-to-end encrypted.** The connection to the server is encrypted (TLS), but the
server stores the file unencrypted and whoever runs the server can read it. Don't share
confidential work through a server you don't trust; run your own
([`docs/server.md`](docs/server.md)) or share on the local network instead.
- **The code or link is the access.** There are no accounts: anyone who has an edit code or link
can open and change the file, anyone with a view code or link can open it. Stop sharing revokes
them and disconnects everyone else.
- **Share ▸ Local network involves no server**, but it is not encrypted either: your app hosts
the session and others connect to it directly, so use it on networks you trust.
- **No guarantees.** The public server is offered as is, without backups or uptime promises, and
files on it can be removed. Keep your own saved copy of anything you share.
The default server's terms are in [`docs/online-sharing-terms.md`](docs/online-sharing-terms.md).
## The Crafting Apps
FigCraft is one of the **Crafting Apps**: free, open-source creative tools from the
+241 -52
View File
@@ -1,8 +1,8 @@
# FigCraft Roadmap
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
**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-11 · **Change:** minor (selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -13,21 +13,21 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (204 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~80%** (core editor ~91%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~49%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~46%** | estimated: weekly areas 67.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~173–310 h** one agent | estimated |
| Remaining to full parity | **~323–621 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~49% | ~305–584 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
| Mainstream practitioner | ~46% | ~140–259 h | core editor depth incl. the `.fig` import's tail (58–112), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
| 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) |
| 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) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@@ -46,52 +46,52 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked
against real Figma documents. Ready-for-real-work is ~49%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
~75% **and** reliable `.fig` import: the `.fig` import's tail (8–15 h), a real-file corpus with measured
against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h) and
Figma's own languages on the landed localization layer (15–25 h).
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
native speakers' reviews of the six language drafts need people.
## By dimension
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 57% (core editor 68%) | 60–115 | [target-app-parity](docs/target-app-parity.md) |
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 20% (layer; English, Spanish draft) | 45–75 | [localization-parity](docs/localization-parity.md) |
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 28% (own WASM plugins with panels and settings) | 15–40 | [gaps](docs/gaps.md) |
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
| Area | Breadth | Ready | Hours | Doc |
|---|---|---|---|---|
| Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) |
| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) |
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
| Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) |
| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) |
| Strokes | 90% | 72% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) |
| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) |
| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) |
| Text | 85% | 64% | 7–13 | [target-app-parity](docs/target-app-parity.md) |
| Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) |
| Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) |
| Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 90% | 64% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) |
| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) |
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 96% | 72% | 0–1 | [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) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 80% | 55% | 2–5 | [gaps](docs/gaps.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
## Languages
@@ -99,35 +99,153 @@ Figma's own languages on the landed localization layer (15–25 h).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | en | 100% | full |
| Simplified Chinese | zh-Hans | 0% | none |
| Spanish | es | 100% | draft (needs native review) |
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| French | fr | 0% | none |
| Portuguese | pt | 0% | none |
| French | fr | 100% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
| Indonesian | id | 0% | none |
| Japanese | ja | 0% | none |
| German | de | 0% | none |
| Korean | ko | 0% | none |
| Japanese | ja | 100% | draft (needs native review) |
| German | de | 100% | draft (needs native review) |
| Korean | ko | 100% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and a Spanish draft (Preferences ▸ Language;
adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic
shaping, IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
## Upcoming
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: editable vector networks, the skipped layer kinds that matter, fixes from real files (the best-effort import landed 2026-10-11): 8–15 h.
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 on the landed localization layer: ja, ko, pt-BR, fr, de, and a native review of es: 15–30 h.
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, 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): 45–90 h.
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
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-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged.
- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged.
- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged.
- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged.
- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged.
- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from
their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as
derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings,
the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the
import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width;
number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged.
text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged.
- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page,
dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a
Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting
started card that can be closed and comes back from the Help menu
- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app
quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online**
lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from
the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a
crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
upgrade only to tell which files are still there. The six draft catalogs translate every string again
- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge
preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and
object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are
kept, and dark pages (white text on them) for files made in the dark theme
- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or
**Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that
carries the host's address); others type it under Join with a code (the popover, the home screen), where a link
works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`,
`collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links)
- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and
compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing
goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before
in the design-system example). One font family; real files still unchecked (gap 3). No number moves
- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at
once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover,
people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`),
and closing a file ends its session only (before, one session followed every tab and starting another replaced it)
- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key
(Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are
kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address,
idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and
`packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups
([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature).
- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other
software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The
export now says that the document and its pages are visible and opaque, numbers its schema as the format does,
and writes outlines, text settings, standard font style names and line height units; effects such as glass and
noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers
unchanged.
- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig
(experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers,
geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and
instances with their overrides, and prototype interactions; what's left out (style links, variables, comments,
Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File
formats 60%.
- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129
shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it
is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on
Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing,
keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control
suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on
Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main
component keys; shifted symbols on other keyboard layouts.
- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key
(`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a
confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are
checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live
per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with
`figcraft-server plugins`. Plugins & widgets 60%.
- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget
plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin,
validated and built with locked layers inside; clicking a part it named calls it back with the widget's state
(kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws;
`widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and
plugin colour fields open the colour picker. Plugins & widgets 55%.
- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land
together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers
at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start
value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names,
and hidden-from-publishing names that decide both ways.
- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit
mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several
Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per
point, and the Pen connecting to its own points or continuing a selected open path.
- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel
grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide,
Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames),
outline mode (clicks reach layers behind) and measuring, each rule from the documentation.
- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows
only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under
them, layer names with slashes export into folders; export settings gained contents only, include bounding box and
the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in
rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%.
- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their
corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes
with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped;
set-variable and conditional prototype actions come across. File formats 58%, ready ~49%.
- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes,
gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal
groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent
layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through.
Paint 70%, effects 75%.
- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and
MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name
onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and
@@ -195,6 +313,77 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 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. |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged |
| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged |
| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged |
| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged |
| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged |
| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged |
| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged |
| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli <args>` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged |
| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged |
| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged |
| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged |
| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged |
| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged |
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. |
| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. |
| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. |
| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged |
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged |
| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged |
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged |
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding |
| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding |
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h |
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h |
| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h |
| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h |
| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h |
| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h |
| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h |
| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h |
| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding |
| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) |
| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h |
| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h |
| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) |
| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) |
| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h |
| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “<branch>”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h |
| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h |
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) |
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h |
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) |
| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding |
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged |
| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h |
+14
View File
@@ -0,0 +1,14 @@
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
fn main() {
main_thread_stack();
}
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
/// to 256 deep in an export, and that recursion needs the same room everywhere.
fn main_thread_stack() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
}
+77 -9
View File
@@ -3,7 +3,7 @@
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind, PrintSettings};
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use serde_json::{Value, json};
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
match name {
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
Some(n) => doc
.pages
.iter()
@@ -102,22 +102,37 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
}
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.map_err(|e| CliError::Failed(e.to_string()))?
.into_bytes(),
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))),
"fig" => fig_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
};
write(out, &bytes)
}
/// `doc` as a `.fig` file (experimental), printing what was left out.
fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
let (bytes, warnings) = figcraft_engine::cmd::file::fig_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)) {
figcraft_engine::cmd::file::frames_pdf(doc, page, print).map(|(bytes, _)| bytes)
} else {
figcraft_engine::cmd::file::export_bytes_with(doc, &children(doc, page), ExportFormat::Pdf, 1.0, print)
figcraft_engine::cmd::file::export_bytes_with(
doc,
&children(doc, page),
&ExportSetting { format: ExportFormat::Pdf, print: *print, ..Default::default() },
1.0,
)
};
r.map_err(|e| CliError::Failed(e.to_string()))
}
@@ -193,16 +208,26 @@ pub struct Target {
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
let doc = load(file)?;
// `.fig`: the whole file.
if t.format.as_deref().map_or(ext(out) == "fig", |f| f.eq_ignore_ascii_case("fig")) {
write(out, &fig_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)")))?,
Some(f) => {
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
}
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]| figcraft_engine::cmd::file::export_bytes_with(&doc, ids, fmt, scale, print).map_err(|e| CliError::Failed(e.to_string()));
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()))
};
if single {
let bytes = match target {
Some(t) => bytes_of(&[t])?,
@@ -354,4 +379,47 @@ mod tests {
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
}
#[test]
fn default_page_skips_leading_divider() {
let mut doc = Document::new("divider");
let page = doc.first_page().unwrap();
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
let divider = doc.add_page("- Divider");
doc.pages.retain(|p| *p != divider);
doc.pages.insert(0, divider);
assert!(doc.is_divider(divider));
assert_eq!(page_named(&doc, None).unwrap(), page);
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
assert_eq!((pm.width(), pm.height()), (40, 30));
assert!(render_page(&doc, divider, 1.0).is_err());
}
#[test]
fn export_depth_errors_preserve_conversion_destinations() {
let mut doc = Document::new("export depth");
let page = doc.first_page().unwrap();
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for _ in 1..257 {
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
}
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
for ext in ["svg", "pdf"] {
let path = dir.join(format!("previous.{ext}"));
std::fs::write(&path, b"previous export").unwrap();
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
panic!("depth overflow must be an operation failure");
};
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
assert!(error.contains("select an inner layer"), "{ext}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
}
+54 -4
View File
@@ -1,5 +1,6 @@
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
//! command scripts, list commands, serve MCP on stdio and measure performance.
//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin
//! directory and measure performance.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod args;
@@ -16,22 +17,31 @@ 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] [--scale N] [--page NAME] [--node ID|NAME]
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 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>]
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT]
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
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; PDFs take print options (a bleed like 3mm or 0.125in,
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,
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)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
See docs/cli.md and docs/mcp.md.";
fn main() -> ExitCode {
@@ -66,6 +76,13 @@ 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 {
// 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();
}
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
match cmd.as_str() {
"help" | "--help" | "-h" => {
@@ -122,11 +139,21 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
mcp(a.value("attach").as_deref())
}
"plugin" => {
let a = Args::parse(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()))?;
publish_plugin(folder, &server)
}
_ => Err(CliError::Usage("expected: plugin publish <folder> --server ws://host:port/".into())),
}
}
"perf" => {
let a = Args::parse(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)
perf::run(&sizes, &instances, system_fonts)
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
@@ -155,6 +182,29 @@ fn commands(as_json: bool, filter: Option<&str>) {
}
}
/// Publish the plugin folder `folder` to the plugin directory at `server` (the key from the address
/// or `FIGCRAFT_SERVER_KEY`); never prints the key.
fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
let address = server.split('?').next().unwrap_or_default().to_string();
let server = match std::env::var("FIGCRAFT_SERVER_KEY") {
Ok(key) if !server.contains("key=") && !key.trim().is_empty() => format!("{address}?key={}", key.trim()),
_ => server.to_string(),
};
let mut h = figcraft_mcp::Headless::new();
use figcraft_mcp::Backend as _;
let r = h.call("engine.execute", json!({"command": "plugin.directory.publish", "params": {"path": folder, "server": server}}))?;
let e = &r["published"];
println!(
"Published {} {} ({}) to {address}: {} bytes, SHA-256 {}",
e["name"].as_str().unwrap_or_default(),
e["version"].as_str().unwrap_or_default(),
e["id"].as_str().unwrap_or_default(),
e["bytes"],
e["sha256"].as_str().unwrap_or_default()
);
Ok(ExitCode::SUCCESS)
}
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {
+69 -37
View File
@@ -292,9 +292,11 @@ fn measure_boolean_drag(table: &mut Table, col: usize) {
/// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design
/// system, for each instance count in `instances`; print the tables.
pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult<ExitCode> {
// Text renders with system fonts too, like the desktop app.
figcraft_text::scan_system_fonts();
pub fn run(sizes: &[usize], instances: &[usize], system_fonts: bool) -> CliResult<ExitCode> {
// Text renders with system fonts too, like the desktop app; scanned up front, outside the timings.
if system_fonts {
figcraft_text::scan_system_fonts();
}
let mut table = Table { rows: Vec::new() };
for (col, n) in sizes.iter().enumerate() {
eprintln!("measuring {n} layers…");
@@ -436,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
);
let doc = s.doc().map_err(failed)?.clone();
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let b = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
.ok_or_else(|| failed("no button glyph to drop on"))?;
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
);
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
} else {
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
}
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
if let Some(nav) = nav {
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
} else {
table.put(col, "reset all changes on a nav", f64::NAN);
table.put(col, "detach a nav", f64::NAN);
}
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let drop_at = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
match drop_at {
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
None => f64::NAN,
},
);
} else {
table.put(col, "push a button's changes to its main", f64::NAN);
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
}
if let Some(nav) = nav {
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
} else {
table.put(col, "copy + paste a nav", f64::NAN);
}
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
table.put(col, "redo", mean(10, |_| _ = s.redo()));
// As a library: publish it, use it from another file, publish a change, accept the update.
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
// One directory per run, so concurrent runs in a process don't delete each other's files.
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
@@ -531,6 +555,14 @@ mod tests {
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_components_with_a_single_instance() {
let mut t = super::Table { rows: Vec::new() };
super::measure_components(&mut t, 0, 1).unwrap();
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_a_small_document() {
let mut t = super::Table { rows: Vec::new() };
+21
View File
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn run_takes_the_documented_gradient_shorthand() {
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
let d = tmp("gradient");
let script = d.join("s.json");
std::fs::write(
&script,
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
)
.unwrap();
let file = d.join("g.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
let json = d.join("g.json");
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
let doc = std::fs::read_to_string(&json).unwrap();
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
+59
View File
@@ -0,0 +1,59 @@
//! Depth refusal through the actual CLI, including its exit status and destination writes.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
use std::process::Command;
#[test]
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
let mut doc = Document::new("export depth");
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for depth in 1..257 {
let name = if depth == 1 { "inner-root" } else { "group" };
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
}
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let input = dir.join("deep.figcraft");
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
for ext in ["svg", "pdf"] {
let path = dir.join(format!("previous.{ext}"));
std::fs::write(&path, b"previous export").unwrap();
for verb in ["convert", "export"] {
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
command.arg(verb).arg(&input);
if verb == "export" {
command.arg("-o");
}
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
let inner = dir.join(format!("inner.{ext}"));
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
.arg("export")
.arg(&input)
.arg("-o")
.arg(&inner)
.args(["--node", "inner-root", "--no-system-fonts"])
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let bytes = std::fs::read(inner).unwrap();
if ext == "svg" {
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
} else {
assert!(bytes.starts_with(b"%PDF"));
}
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
std::fs::remove_dir_all(dir).unwrap();
}
+47
View File
@@ -0,0 +1,47 @@
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
use serde_json::{Value, json};
/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment
/// variable.
fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join(format!("{name}.json"));
std::fs::write(&script, json!([step]).to_string()).unwrap();
let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
cmd.arg("run").arg(&script).args(args);
if opt_out {
cmd.env("FIGCRAFT_NO_SYSTEM_FONTS", "1");
} else {
cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS");
}
let out = cmd.output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let line: Value = serde_json::from_slice(&out.stdout).unwrap();
line["result"].clone()
}
fn families(args: &[&str], opt_out: bool) -> Vec<String> {
serde_json::from_value(run_one("families", json!({"command": "text.fonts"}), args, opt_out)).unwrap()
}
#[test]
fn headless_runs_resolve_installed_families_unless_opted_out() {
let built_in = families(&["--no-system-fonts"], false);
assert_eq!(families(&[], true), built_in, "FIGCRAFT_NO_SYSTEM_FONTS=1 opts out like the flag");
// Any family this machine adds (scanned here, in the test); none (CI): nothing to check.
figcraft_text::scan_system_fonts();
let Some(installed) = figcraft_text::family_names().into_iter().find(|f| !built_in.contains(f)) else {
eprintln!("skipped: no installed fonts beyond the built-in ones");
return;
};
assert!(families(&[], false).contains(&installed), "headless runs list {installed}");
let face = json!({"command": "text.fonts", "params": {"family": installed, "axes": true}});
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
}
+9
View File
@@ -21,6 +21,8 @@ figcraft-collab = { workspace = true }
figcraft-collab-net = { workspace = true }
# Checking a team library before keeping it (read as files using it read it).
figcraft-format = { workspace = true }
# Checking plugin bundles before the plugin directory keeps them (never run here).
figcraft-plugin = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -31,7 +33,14 @@ env_logger = { version = "0.11", default-features = false, features = ["humantim
# Stopping gracefully on SIGTERM/SIGINT/SIGHUP (systemd, Docker, Ctrl+C) and Ctrl+C/close on Windows.
ctrlc = { version = "3", features = ["termination"] }
# Free space on the data directory's disk (statvfs, without unsafe code of our own).
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "fs"] }
[dev-dependencies]
# Crafting hostile archives (zip bombs) in tests.
zip = { workspace = true }
wat = { workspace = true }
figcraft-engine = { workspace = true }
figcraft-doc = { workspace = true }
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
+227
View File
@@ -0,0 +1,227 @@
//! What one address may do how often: new connections, publishing without the key and messages
//! (only on a server open to the public), and reports and downloads (always). Each is a token
//! bucket per block an address counts in ([`ip_blocks`]: an IPv4 address, or an IPv6 /64 and its
//! /56 and /48 with 2 and 4 times the rate), so a tunnel's /48 doesn't get 65,536 /64s' worth. Publishing has a budget for
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
//! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood
//! of addresses costs bounded memory (and forgotten addresses start with full buckets).
//!
//! Join codes are short, so guessing them is held back three ways: an address may try
//! [`CODES_PER_MINUTE`] codes a minute, [`WRONG_CODES`] wrong ones in a row lock it out of codes for
//! [`CODE_LOCKOUT`] (links still join), and everyone together may get [`WRONG_CODES_PER_HOUR`]
//! wrong an hour (an attacker with many addresses is slowed to that; while it lasts, people join
//! with their links).
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use figcraft_collab_net::hub::{Block, ip_blocks};
use figcraft_collab_net::net::Bucket;
/// Most addresses remembered.
pub const MAX_ADDRESSES: usize = 10_000;
/// Reports one address may send an hour.
pub const REPORTS_PER_HOUR: f64 = 10.0;
/// Bytes one address may download a second (team libraries, plugins), and at once: an IPv6 /48
/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once).
pub const DOWNLOAD_RATE: f64 = (1u64 << 20) as f64;
pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
/// Everyone together may publish without the key this many times what one address may: eight
/// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all.
pub const PUBLISHES_ALL: f64 = 32.0;
/// Join codes one address may try a minute (right or wrong).
pub const CODES_PER_MINUTE: f64 = 10.0;
/// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes.
pub const WRONG_CODES: u32 = 10;
/// How long an address is locked out of join codes.
pub const CODE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
/// Wrong codes everyone together may try an hour.
pub const WRONG_CODES_PER_HOUR: f64 = 1_000.0;
/// The rates (see the server's settings).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AbuseConfig {
/// Open to the public: connections, publishing and messages are limited.
pub open: bool,
pub connects_per_minute: u32,
pub publishes_per_hour: u32,
pub messages_per_second: u32,
}
/// What an address does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Connect,
Publish,
Message,
/// Presence (cursors, selections, viewports), cursor chat and pings: twice `messages_per_second`.
Presence,
Report,
/// Downloading this many bytes.
Download(usize),
/// Trying a join code.
Code,
}
struct Address {
connects: Bucket,
publishes: Bucket,
messages: Bucket,
presence: Bucket,
reports: Bucket,
downloads: Bucket,
codes: Bucket,
/// Wrong join codes in a row, and when the last one was.
wrong: u32,
wrong_at: Instant,
/// Locked out of join codes until then.
locked: Option<Instant>,
used: Instant,
}
/// The buckets of the addresses seen lately.
pub struct Abuse {
cfg: AbuseConfig,
table: HashMap<Block, Address>,
/// Publishing without the key, everyone together.
publishes: Bucket,
/// Wrong join codes, everyone together.
wrong_codes: Bucket,
}
/// `n` every `period` seconds on average, and all `n` at once at most.
fn bucket(n: f64, period: f64) -> Bucket {
Bucket::new(n / period, n.max(1.0))
}
impl Abuse {
pub fn new(cfg: AbuseConfig) -> Self {
let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0);
Self { cfg, table: HashMap::new(), publishes, wrong_codes: bucket(WRONG_CODES_PER_HOUR, 3600.0) }
}
/// How long `ip` is still locked out of join codes (in any block it is in), if it is.
pub fn code_locked(&self, ip: IpAddr, now: Instant) -> Option<Duration> {
let until = ip_blocks(ip).into_iter().filter_map(|(k, _)| self.table.get(&k)?.locked).max()?;
Some(until.saturating_duration_since(now)).filter(|d| !d.is_zero())
}
/// Everyone together has wrong codes left to try (see [`WRONG_CODES_PER_HOUR`]).
pub fn codes_open(&mut self, now: Instant) -> bool {
let open = self.wrong_codes.take(now);
if open {
self.wrong_codes.give(1.0);
}
open
}
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
/// before it; enough wrong ones in a row lock the address out.
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
if !ok {
self.wrong_codes.take(now);
}
for (k, times) in ip_blocks(ip) {
let a = self.address(k, f64::from(times), now);
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
a.wrong += 1;
a.wrong_at = now;
if a.wrong >= WRONG_CODES.saturating_mul(times) {
a.wrong = 0;
a.locked = Some(now + CODE_LOCKOUT);
}
}
}
}
/// Whether `ip` may do `what` now (counting it, in every block it is in, when it may).
pub fn allow(&mut self, ip: IpAddr, what: Action, now: Instant) -> bool {
if !self.cfg.open && matches!(what, Action::Connect | Action::Publish | Action::Message | Action::Presence) {
return true;
}
let keys: Vec<(Block, f64)> = ip_blocks(ip).into_iter().map(|(k, times)| (k, f64::from(times))).collect();
// Every block must have room before any is charged.
if !keys.iter().all(|&(k, times)| self.room(k, times, what, now)) {
return false;
}
if what == Action::Publish && !self.publishes.take(now) {
return false;
}
for &(k, times) in &keys {
let (b, n) = Self::pick(self.address(k, times, now), what);
b.take_n(now, n);
}
true
}
/// The address's buckets, made when it is new (forgetting the oldest half when full).
fn address(&mut self, ip: Block, times: f64, now: Instant) -> &mut Address {
if !self.table.contains_key(&ip) && self.table.len() >= MAX_ADDRESSES {
self.forget_oldest_half();
}
let c = self.cfg;
let a = self.table.entry(ip).or_insert_with(|| Address {
connects: bucket(f64::from(c.connects_per_minute) * times, 60.0),
publishes: bucket(f64::from(c.publishes_per_hour) * times, 3600.0),
// A burst of four seconds' worth (a big paste, a quick drag).
messages: Bucket::new(f64::from(c.messages_per_second) * times, f64::from(c.messages_per_second) * times * 4.0),
presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0),
reports: bucket(REPORTS_PER_HOUR * times, 3600.0),
downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times),
codes: bucket(CODES_PER_MINUTE * times, 60.0),
wrong: 0,
wrong_at: now,
locked: None,
used: now,
});
a.used = now;
a
}
fn pick(a: &mut Address, what: Action) -> (&mut Bucket, f64) {
match what {
Action::Connect => (&mut a.connects, 1.0),
Action::Publish => (&mut a.publishes, 1.0),
Action::Message => (&mut a.messages, 1.0),
Action::Presence => (&mut a.presence, 1.0),
Action::Report => (&mut a.reports, 1.0),
Action::Download(n) => (&mut a.downloads, n as f64),
Action::Code => (&mut a.codes, 1.0),
}
}
/// The block has `what`'s tokens now (nothing is spent).
fn room(&mut self, ip: Block, times: f64, what: Action, now: Instant) -> bool {
let (b, n) = Self::pick(self.address(ip, times, now), what);
if b.take_n(now, n) {
b.give(n);
return true;
}
false
}
/// Addresses remembered.
pub fn len(&self) -> usize {
self.table.len()
}
/// None remembered.
pub fn is_empty(&self) -> bool {
self.table.is_empty()
}
fn forget_oldest_half(&mut self) {
let mut used: Vec<Instant> = self.table.values().map(|a| a.used).collect();
used.sort_unstable();
let Some(&cut) = used.get(used.len() / 2) else { return };
self.table.retain(|_, a| a.used > cut);
}
}
@@ -0,0 +1,95 @@
//! The app's way of talking to a server without waiting (`Session::start_background`), end to end
//! over loopback: browsing and installing from the plugin directory, publishing a team library,
//! adding it and asking for its usage each return at once and finish when the answers arrive; a
//! server that never answers doesn't hold the caller up.
use std::net::TcpListener;
use std::time::{Duration, Instant};
use figcraft_engine::Session;
use figcraft_plugin::bundle::Bundle;
use serde_json::{Value, json};
use crate::store::Store;
use crate::test_util::{TempDir, config, publish, pump, session};
const ID: &str = "org.example.tiny";
fn bundle() -> Vec<u8> {
let wasm =
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}});
Bundle { manifest: manifest.to_string(), wasm, assets: Vec::new() }.encode()
}
/// Start `cmd` without waiting (it must not finish at once: its answer comes over the network),
/// then pump the session until it finished; its result.
fn background(s: &mut Session, cmd: &str, params: Value) -> Value {
let started = Instant::now();
assert_eq!(s.start_background(cmd, params).unwrap(), None, "{cmd} waits for its answer");
assert!(started.elapsed() < Duration::from_secs(1), "{cmd} returned at once");
pump(&mut [&mut *s], cmd, |s| !s[0].waiting_for(cmd));
let (done, r) = s.take_background_done().unwrap();
assert_eq!(done, cmd);
r.unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> Value {
v["id"].clone()
}
#[test]
fn browsing_and_installing_finish_in_the_background() {
let dir = TempDir::new("background-plugins");
Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle()).unwrap();
let mut server = crate::start(&config(dir.path())).unwrap();
let plain = format!("ws://{}/", server.addr());
let mut s = session("Bo");
let listed = background(&mut s, "plugin.directory.list", json!({"server": plain}));
assert_eq!(listed["plugins"][0]["id"], ID);
assert_eq!(s.plugins.directory.as_ref().map(|l| l.entries.len()), Some(1), "Browse shows the listing");
let sha = listed["plugins"][0]["sha256"].clone();
let r = background(&mut s, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha, "server": plain}));
assert_eq!(r["installed"], ID);
assert_eq!(s.prefs.plugins.len(), 1);
assert!(s.plugins.unsaved, "the app saves the plugin list at once");
// Scripts still wait for their answer.
assert_eq!(s.execute("plugin.directory.list", &json!({})).unwrap()["plugins"][0]["installed"], "1.0.0");
server.stop();
}
#[test]
fn team_library_requests_finish_in_the_background() {
let dir = TempDir::new("background-library");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let f = a.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})).unwrap();
a.execute("component.create", &json!({"ids": [id(&f)]})).unwrap();
let p = publish(&mut a, dir.path(), addr);
// Fetching the server's current library, then publishing: two answers.
assert_eq!(background(&mut a, "library.publishToServer", json!({}))["version"], 1);
let mut b = session("Bo");
let added = background(&mut b, "library.add", json!({"path": p.view}));
assert_eq!(added["components"].as_array().map(Vec::len), Some(1));
assert_eq!(b.doc().unwrap().libraries.enabled.len(), 1);
let usage = background(&mut a, "library.usage", json!({}));
assert!(usage["files"].is_array(), "{usage}");
server.stop();
}
#[test]
fn a_server_that_never_answers_holds_nothing_up() {
// It never accepts: connections wait in its backlog, and no answer ever comes.
let hole = TcpListener::bind("127.0.0.1:0").unwrap();
let server = format!("ws://{}/", hole.local_addr().unwrap());
let mut s = session("Cy");
let started = Instant::now();
assert_eq!(s.start_background("plugin.directory.list", json!({"server": server})).unwrap(), None);
assert!(started.elapsed() < Duration::from_secs(1), "the caller didn't wait");
assert!(s.waiting_for("plugin.directory.list"));
// Closing the window stops waiting; the answer, when the request gives up, is dropped.
s.cancel_background("plugin.directory.list");
assert!(!s.waiting_for("plugin.directory.list") && s.take_background_done().is_none());
drop(hole);
}
+186 -31
View File
@@ -1,5 +1,5 @@
//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data
//! directory. When a server is running on the directory they ask it (so revoking a link
//! The command line: `figcraft-server` serves; `key`, `files`, `links` and `plugins` manage the
//! data directory. When a server is running on the directory they ask it (so revoking a link
//! disconnects whoever joined with it at once); otherwise they change the directory themselves.
use std::fmt::Write as _;
@@ -7,7 +7,7 @@ use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
use figcraft_collab::message::{Access, Admin, AdminReply, FileRow, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq, ReportInfo};
use figcraft_collab_net::hub::Backend;
use figcraft_collab_net::{Endpoint, Invite, manage};
use serde::{Deserialize, Serialize};
@@ -25,34 +25,67 @@ figcraft-server: FigCraft's collaboration server (files, share links, live sessi
Usage:
figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
figcraft-server key [--rotate] the address to publish to, with the server key
figcraft-server files [list] the files kept
figcraft-server check check the settings (after editing the --config file)
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
figcraft-server files remove FILE remove a file and its links for good
figcraft-server links list FILE a file's share links
figcraft-server links create FILE --access edit|view [--label TEXT]
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
figcraft-server plugins [list] the plugin directory: every plugin and its versions
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
figcraft-server reports what people reported about files
figcraft-server block-ip IP refuse an address (an IPv6 address: its /64) and drop its connections
figcraft-server unblock-ip IP accept an address again
figcraft-server blocked the blocked addresses
Options (also keys of the --config TOML file):
--listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb
max_connections, max_people, compact_after, compact_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
-h, --help this text
-V, --version the version
Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
Logs go to standard output (RUST_LOG=debug for more); they never contain keys, tokens or file contents.
GET /healthz on the same port answers \"ok\" (for uptime checks).
";
/// How `files list` orders the files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sort {
/// By id.
#[default]
Id,
/// Biggest first.
Size,
/// Oldest first.
Age,
/// Longest unused first.
Idle,
}
/// What to do.
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Serve,
Key { rotate: bool },
Files,
Files { sort: Sort },
RemoveFile { file: String },
Links { file: String },
CreateLink { file: String, access: Access, label: String },
Revoke { file: String, link: String },
Plugins,
RemovePlugin { id: String, version: Option<String> },
Reports,
Check,
Block { ip: String },
Unblock { ip: String },
Blocked,
Help,
Version,
}
@@ -75,11 +108,15 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
let mut access = None;
let mut label = String::new();
let mut rotate = false;
let mut version = None;
let mut sort = Sort::Id;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
match a.as_str() {
"-h" | "--help" => return Ok(Cli { config, command: Command::Help }),
// After `plugins remove ID`: the version to unpublish.
"--version" if words.first().is_some_and(|w| w == "plugins") => version = Some(value("--version")?),
"-V" | "--version" => return Ok(Cli { config, command: Command::Version }),
"--config" => {
value("--config")?;
@@ -90,6 +127,14 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
"--label" => label = value("--label")?,
"--rotate" => rotate = true,
"--sort" => {
sort = match value("--sort")?.as_str() {
"size" => Sort::Size,
"age" => Sort::Age,
"idle" => Sort::Idle,
_ => return Err("--sort must be size, age or idle".into()),
}
}
s if s.starts_with('-') => return Err(format!("unknown option {s}")),
s => words.push(s.to_string()),
}
@@ -98,13 +143,20 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
let command = match w.as_slice() {
[] | ["serve"] => Command::Serve,
["key"] => Command::Key { rotate },
["files"] | ["files", "list"] => Command::Files,
["files"] | ["files", "list"] => Command::Files { sort },
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
["links", "list", file] => Command::Links { file: file.to_string() },
["links", "create", file] => {
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
}
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
["plugins"] | ["plugins", "list"] => Command::Plugins,
["plugins", "remove", id] => Command::RemovePlugin { id: id.to_string(), version },
["reports"] => Command::Reports,
["check"] => Command::Check,
["block-ip", ip] => Command::Block { ip: ip.to_string() },
["unblock-ip", ip] => Command::Unblock { ip: ip.to_string() },
["blocked"] => Command::Blocked,
_ => return Err(format!("unknown command `{}`", words.join(" "))),
};
config.check()?;
@@ -151,8 +203,29 @@ pub fn run(cli: &Cli) -> Result<String, String> {
Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
Command::Key { rotate } => key(cfg, *rotate),
Command::Files => match admin(cfg, Admin::Files)? {
AdminReply::Files(files) => Ok(files_table(&files)),
Command::Files { sort } => match admin(cfg, Admin::Listing)? {
AdminReply::Listing(files) => Ok(files_table(files, *sort, store::now())),
other => unexpected(other),
},
// `parse` checked them.
Command::Check => {
Ok(format!("The settings are fine{}.\n", if cfg.open_publish { ": open to the public (anyone may publish without the key)" } else { "" }))
}
Command::Reports => match admin(cfg, Admin::Reports)? {
AdminReply::Reports(reports) => Ok(reports_table(&reports)),
other => unexpected(other),
},
Command::Block { ip } => match admin(cfg, Admin::Block { ip: ip.clone() })? {
AdminReply::Done => Ok(format!("Blocked {ip}: its connections were dropped and new ones are refused.\n")),
other => unexpected(other),
},
Command::Unblock { ip } => match admin(cfg, Admin::Unblock { ip: ip.clone() })? {
AdminReply::Done => Ok(format!("Unblocked {ip}.\n")),
other => unexpected(other),
},
Command::Blocked => match admin(cfg, Admin::Blocked)? {
AdminReply::Blocked(list) if list.is_empty() => Ok("No addresses are blocked.\n".into()),
AdminReply::Blocked(list) => Ok(list.iter().map(|ip| format!("{ip}\n")).collect()),
other => unexpected(other),
},
Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
@@ -173,9 +246,59 @@ pub fn run(cli: &Cli) -> Result<String, String> {
AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")),
other => unexpected(other),
},
Command::Plugins => match directory(cfg, PluginReq::List { query: String::new() })? {
PluginReply::List(entries) => Ok(plugins_table(&entries)),
other => unexpected_plugins(other),
},
Command::RemovePlugin { id, version } => match directory(cfg, PluginReq::Unpublish { id: id.clone(), version: version.clone() })? {
PluginReply::Removed(versions) => Ok(format!("Unpublished {id} {}.\n", versions.join(", "))),
other => unexpected_plugins(other),
},
}
}
fn unexpected_plugins(reply: PluginReply) -> Result<String, String> {
match reply {
PluginReply::Failed { reason } => Err(reason),
_ => Err("the server answered something else".into()),
}
}
/// Do one plugin directory request with the server key: through the running server, or on the
/// data directory.
fn directory(cfg: &Config, req: PluginReq) -> Result<PluginReply, String> {
match Store::try_open(&cfg.data, cfg.store())? {
Some(mut store) => Ok(store.directory().handle(true, req)),
None => {
let addr = running(&cfg.data)?;
let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
manage::plugins(&Endpoint::plain(addr.to_string()), key.trim(), req, &cfg.net(), Duration::from_secs(5))
}
}
}
fn plugins_table(entries: &[PluginEntry]) -> String {
if entries.is_empty() {
return "No plugins yet (publish one with `figcraft-cli plugin publish FOLDER --server ws://host:port/?key=KEY`).\n".into();
}
let mut out =
String::from("ID VERSIONS NAME AUTHOR ACCESS SIZE SHA-256\n");
for e in entries {
let _ = writeln!(
out,
"{:<32} {:<18} {:<20} {:<16} {:<6} {:<9} {}",
elide(&e.id, 32),
elide(&e.versions.join(","), 18),
elide(&e.name, 20),
elide(&e.author, 16),
e.access,
size(e.bytes),
e.sha256.get(..16).unwrap_or_default()
);
}
out
}
fn unexpected(reply: AdminReply) -> Result<String, String> {
match reply {
AdminReply::Failed { reason } => Err(reason),
@@ -221,9 +344,12 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
if rotate {
out.push_str("A new server key: the old one no longer works.\n");
}
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
let _ = writeln!(out, "Publish to this address (in FigCraft: File > Publish to server…):");
let _ = writeln!(out, " {}?key={key}", public.url());
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
if cfg.open_publish {
let _ = writeln!(out, "Open to the public: anyone can publish to {} without the key (each file gets an owner token).", public.url());
}
Ok(out)
}
@@ -242,17 +368,60 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
Ok(out)
}
fn files_table(files: &[FileInfo]) -> String {
/// When a listed file was last used (made, changed or opened).
fn used(f: &FileRow) -> u64 {
f.info.created.max(f.info.updated).max(f.opened)
}
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
if files.is_empty() {
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
return "No files yet (publish one from FigCraft: File > Publish to server…).\n".into();
}
let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
for f in files {
let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
match sort {
Sort::Id => {}
Sort::Size => files.sort_by_key(|f| std::cmp::Reverse(f.info.bytes)),
Sort::Age => files.sort_by_key(|f| f.info.created),
Sort::Idle => files.sort_by_key(used),
}
let mut out = String::from("ID NAME PEOPLE SIZE CREATED IDLE EXPIRES REPORTS BY\n");
for f in &files {
let idle = format!("{}d", now.saturating_sub(used(f)) / 86_400);
let expires = f.expires.map_or_else(|| "never".to_string(), day);
let by = if f.open { f.ip.as_str() } else { "key" };
let _ = writeln!(
out,
"{:<16} {:<30} {:>6} {:<9} {:<10} {:>4} {:<10} {:>7} {}",
f.info.id,
elide(&f.info.name, 30),
f.info.people,
size(f.info.bytes),
day(f.info.created),
idle,
expires,
f.reports,
by
);
}
out
}
fn reports_table(reports: &[ReportInfo]) -> String {
if reports.is_empty() {
return "No reports.\n".into();
}
let mut out = String::new();
for r in reports {
let _ = writeln!(out, "{} file {} ({}) from {} link {}", date(r.at), r.file, elide(&r.name, 40), r.ip, r.link);
let _ = writeln!(out, " {}", r.reason);
}
out
}
/// `YYYY-MM-DD` for seconds since 1970.
fn day(secs: u64) -> String {
date(secs).chars().take(10).collect()
}
fn links_table(links: &[LinkInfo]) -> String {
if links.is_empty() {
return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
@@ -277,18 +446,4 @@ fn size(bytes: u64) -> String {
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
pub fn date(secs: u64) -> String {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
pub use figcraft_collab::message::utc_date as date;
+6 -6
View File
@@ -5,7 +5,7 @@ use figcraft_collab::Replica;
use figcraft_collab::message::Access;
use figcraft_doc::Document;
use crate::cli::{self, Cli, Command, date, parse};
use crate::cli::{self, Cli, Command, Sort, date, parse};
use crate::config::Config;
use crate::store::Store;
use crate::test_util::{TempDir, config};
@@ -36,7 +36,7 @@ fn parsing() {
parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command,
Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() }
);
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files);
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files { sort: Sort::Id });
assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true });
assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help);
for bad in
@@ -60,7 +60,7 @@ fn parsing() {
fn managing_a_directory_on_its_own() {
let dir = TempDir::new("cli-offline");
let file = with_file(dir.path());
let list = run(dir.path(), Command::Files).unwrap();
let list = run(dir.path(), Command::Files { sort: Sort::Id }).unwrap();
assert!(list.contains(&file) && list.contains("Board"), "{list}");
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap();
assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}");
@@ -75,7 +75,7 @@ fn managing_a_directory_on_its_own() {
let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap();
assert_ne!(key.lines().nth(1), rotated.lines().nth(2));
run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap();
assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet"));
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("No files yet"));
assert!(run(dir.path(), Command::Links { file }).is_err());
}
@@ -91,12 +91,12 @@ fn managing_through_the_running_server() {
assert!(links.contains("Team"), "{links}");
let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked"));
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server"));
assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr())));
server.stop();
// Stopped: the directory is free again.
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
}
#[test]
+86
View File
@@ -0,0 +1,86 @@
//! Join codes on the server: a short name for one share link, typed in place of the link.
//!
//! A code is 45 random bits ([`figcraft_collab::code`]). Like a link's token it is never stored:
//! a link keeps the code's hash, to find the link by, and its own token wrapped with the code, to
//! hand to whoever types the code (they then join as with the link). Both come from the code
//! stretched with SHA-256 ([`STRETCH`] rounds), so a copy of the data directory doesn't give the
//! codes away cheaply; guessing against a running server is held back by the limits on wrong codes
//! (`abuse.rs`).
use figcraft_collab::{code, same_secret};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Rounds of SHA-256 a code goes through before it is compared or used as a key.
pub const STRETCH: u32 = 10_000;
// A copy of the data directory mustn't give codes away cheaply.
const _: () = assert!(STRETCH >= 10_000);
/// Longest token a code can wrap (one SHA-256 output).
const MAX_TOKEN: usize = 32;
/// What a link keeps of its join code (`links.json`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sealed {
/// The stretched code's hash, hex: what a typed code is looked up by.
pub hash: String,
/// The link's token, wrapped with the stretched code (hex).
pub token: String,
}
/// A typed code, stretched: ready to look links up with (what it is found by, and the pad its
/// link's token is wrapped with).
pub struct Key {
hash: String,
pad: [u8; 32],
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
impl Key {
/// Stretch `code` (its symbols, as [`code::normalize`] gives them).
pub fn new(code: &str) -> Key {
let mut k: [u8; 32] = Sha256::new().chain_update(b"figcraft join code\0").chain_update(code.as_bytes()).finalize().into();
for _ in 0..STRETCH {
k = Sha256::digest(k).into();
}
let part = |what: &[u8]| -> [u8; 32] { Sha256::new().chain_update(what).chain_update(k).finalize().into() };
Key { hash: hex(&part(b"find\0")), pad: part(b"wrap\0") }
}
/// What a link with this code keeps as [`Sealed::hash`].
pub fn hash(&self) -> &str {
&self.hash
}
/// Wrap or unwrap `bytes` (the same operation: XOR with a pad only this code gives).
fn wrap(&self, bytes: &[u8]) -> Vec<u8> {
bytes.iter().zip(self.pad).map(|(b, p)| b ^ p).collect()
}
/// Keep `token` under this code.
pub fn seal(&self, token: &str) -> Result<Sealed, String> {
if token.len() > MAX_TOKEN {
return Err("this link's token is too long for a join code".into());
}
Ok(Sealed { hash: self.hash.clone(), token: hex(&self.wrap(token.as_bytes())) })
}
/// The token `sealed` keeps, when this is its code (compared in constant time).
pub fn open(&self, sealed: &Sealed) -> Option<String> {
if !same_secret(&self.hash, &sealed.hash) || sealed.token.len() > 2 * MAX_TOKEN {
return None;
}
let wrapped: Option<Vec<u8>> =
(0..sealed.token.len() / 2).map(|i| sealed.token.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
String::from_utf8(self.wrap(&wrapped?)).ok()
}
}
/// A new code's symbols, from the operating system's random numbers.
pub fn new_code() -> Result<String, String> {
figcraft_collab_net::token::random().map(code::online)
}
+404
View File
@@ -0,0 +1,404 @@
//! Join codes on the server: sharing online from one app and joining by code from another (real
//! links over loopback; stopping revokes the others' links and codes and disconnects them while
//! the owner stays in the file, and sharing again makes new ones for the same file), codes kept
//! only hashed, revoked with their link and gone with their file,
//! the limits on wrong codes, junk requests, and clients from before join codes.
use std::net::IpAddr;
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::code;
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::{Document, NodeKind};
use figcraft_engine::Session;
use figcraft_engine::live::Status;
use serde_json::{Value, json};
use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, WRONG_CODES, WRONG_CODES_PER_HOUR};
use crate::codes::Key;
use crate::config::Config;
use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store};
use crate::test_util::{Raw, TempDir, config, live, pump, session};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "[email protected]".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// An app whose server for online sharing is the one at `url`.
fn app(name: &str, url: &str) -> Session {
let mut s = session(name);
ex(&mut s, "pref.publishServer", json!({"url": url}));
s
}
fn layers(s: &Session) -> usize {
s.doc().map_or(0, |d| d.node_count())
}
fn failed(s: &Session) -> Option<String> {
match &s.live().status {
Status::Failed { reason } => Some(reason.clone()),
_ => None,
}
}
fn is_code(shown: &str) -> bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
let mut server = crate::start(&open(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = app("Ada", &url);
// Sharing: the file goes to the server in Preferences, and the server names its links.
let started = ex(&mut a, "collab.share", json!({}));
assert_eq!((&started["status"], &started["share"]["via"], &started["code"]), (&json!("connecting"), &json!("online"), &Value::Null));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
let shared = a.live_summary();
let (edit, view) = (shared["share"]["editCode"].as_str().unwrap().to_string(), shared["share"]["viewCode"].as_str().unwrap().to_string());
assert!(is_code(&edit) && is_code(&view) && edit != view, "{edit} {view}");
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
let p = a.live().published.clone().unwrap();
assert!(p.owner.is_some(), "the owner token is kept as before");
// Others get links of their own (the owner keeps the file's first ones): four in all.
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
let ids = |dir: &std::path::Path| -> Vec<String> {
let links: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("files").join(&p.file).join("links.json")).unwrap()).unwrap();
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
};
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);
// 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");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(b.live_summary()["link"], edit_link.as_str(), "joined as with the shared link: reconnects need no code");
let before = layers(&a);
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a, &mut b], "Ada's edit", |s| layers(s[1]) == before + 1);
ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a, &mut b], "Bo's edit", |s| layers(s[0]) == before + 2);
// The view code lets someone look, not edit.
assert_eq!(ex(&mut a, "collab.share", json!({"access": "view"}))["code"], view.as_str());
let mut c = app("Cy", &url);
ex(&mut c, "collab.join", json!({"code": view}));
pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]) && layers(s[2]) == before + 2);
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("view"), &json!(true)));
assert!(c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
// A code nobody was given.
let mut d = app("Di", &url);
ex(&mut d, "collab.join", json!({"code": "000-000-000"}));
pump(&mut [&mut d], "the refusal", |s| failed(s[0]).is_some());
assert_eq!(failed(&d).unwrap(), NO_CODE);
// Stop sharing: the others are out at once and told why; the file and its owner stay.
assert_eq!(ex(&mut a, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
pump(&mut [&mut a, &mut b, &mut c], "the others out", |s| {
!s[1].live().status.active() && !s[2].live().status.active() && s[0].live().peers.is_empty()
});
for guest in [&b, &c] {
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
}
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
let folder = dir.path().join("files").join(&p.file);
assert!(folder.exists() && live(&a), "the file stays on the server and Ada stays in it");
let stopped = a.live_summary();
assert_eq!((&stopped["share"]["shared"], &stopped["code"], &stopped["published"]["file"]), (&json!(false), &Value::Null, &json!(p.file)));
assert_eq!(ids(dir.path()), before_stop[..2], "the owner's links are left, the shared ones are gone");
// Neither the codes nor the links get anyone in any more.
let mut e = app("Ed", &url);
for old in [&edit, &view] {
ex(&mut e, "collab.join", json!({"code": old}));
pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some());
assert_eq!(failed(&e).unwrap(), CODE_GONE);
}
ex(&mut e, "collab.join", json!({"link": edit_link}));
pump(&mut [&mut e], "the stopped link", |s| failed(s[0]).is_some_and(|why| why.contains("isn't valid")));
// The owner still edits the server's file.
ex(&mut a, "shape.rectangle", json!({"x": 200, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a], "the owner's edit confirmed", |s| layers(s[0]) == before + 3 && s[0].live().client().is_some_and(|c| c.pending() == 0));
// Sharing again: new codes and links for the same file (nothing is published twice).
let again = ex(&mut a, "collab.share", json!({}));
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
assert_eq!(ids(dir.path()).len(), 4);
ex(&mut b, "collab.join", json!({"code": edit2}));
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
// The old code is still gone.
ex(&mut e, "collab.join", json!({"code": edit}));
pump(&mut [&mut e], "the old code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
server.stop();
}
#[test]
fn a_keyed_share_gets_codes_and_stops_with_the_key() {
let dir = TempDir::new("codes-keyed");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
ex(&mut a, "collab.share", json!({"server": format!("{url}?key={}", crate::test_util::key(dir.path())), "name": "Team file"}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
let shown = a.live_summary()["code"].as_str().unwrap().to_string();
assert!(is_code(&shown));
assert!(!a.live_summary().to_string().contains("key="), "the key is nowhere in the summary");
let mut b = app("Bo", &url);
ex(&mut b, "collab.join", json!({"code": shown}));
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
// No owner token: the key the address carried stops the sharing (the file is the operator's).
assert_eq!(ex(&mut a, "collab.stop", json!({}))["shared"], false);
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active());
assert!(live(&a) && !a.live_summary().to_string().contains("key="));
ex(&mut b, "collab.join", json!({"code": shown}));
pump(&mut [&mut b], "the stopped code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
// Leaving keeps the file there.
ex(&mut a, "collab.leave", json!({}));
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1);
server.stop();
}
#[test]
fn codes_are_hashed_replaced_revoked_and_gone_with_the_file() {
let dir = TempDir::new("codes-store");
let cfg = open(dir.path());
let me = ip("192.0.2.7");
let (file, edit_token, view_link, shown, view_code) = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, edit, view, _owner) = store.publish_open(me, "Poster", &snapshot("Poster")).unwrap();
let shown = store.set_code(&file.id, &edit.token).unwrap();
let view_code = store.set_code(&file.id, &view.token).unwrap();
assert_ne!(shown, view_code, "a code per link");
assert_eq!((shown.len(), code::normalize(&shown).as_deref()), (code::ONLINE_LEN, Ok(shown.as_str())));
// The code gives the link's token back, and the token admits with the link's access.
assert_eq!(store.resolve_code(me, &shown).unwrap(), (file.id.clone(), edit.token.clone()));
let (f, t) = store.resolve_code(me, &code::grouped(&view_code).to_lowercase()).unwrap();
assert_eq!(store.admit(&f, &t).unwrap().access, figcraft_collab::Access::View);
// A token that isn't one of the file's links gets no code.
assert!(store.set_code(&file.id, "AAAAAAAAAAAAAAAAAAAAAA").unwrap_err().contains("isn't one of this file's links"));
assert!(store.set_code("0000000000000000", &edit.token).is_err());
(file.id, edit.token, view.link.id, shown, view_code)
};
// What is kept: the stretched code's hash and the wrapped token, nothing that is either.
let links = std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap();
assert!(!links.contains(&shown) && !links.contains(&edit_token), "{links}");
assert!(links.contains(Key::new(&shown).hash()));
// A restart keeps the codes (they are in the data directory).
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
assert_eq!(store.resolve_code(me, &shown).unwrap().1, edit_token);
// A new code for the link takes the old one's place.
let newer = store.set_code(&file, &edit_token).unwrap();
assert_ne!(newer, shown);
assert_eq!(store.resolve_code(me, &shown).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(me, &newer).unwrap().1, edit_token);
// Revoking a link revokes its code.
store.revoke(&file, &view_link).unwrap();
assert_eq!(store.resolve_code(me, &view_code).unwrap_err(), CODE_GONE);
// The file expiring takes the rest.
store.age(&file, 31 * 86_400);
assert_eq!(store.sweep(), vec![file.clone()]);
assert_eq!(store.resolve_code(me, &newer).unwrap_err(), CODE_GONE);
// A code never handed out is unknown, not expired.
assert_eq!(store.resolve_code(ip("192.0.2.8"), "ZZZZZZZZZ").unwrap_err(), NO_CODE);
}
#[test]
fn wrong_codes_lock_an_address_out_but_not_the_others() {
let dir = TempDir::new("codes-lockout");
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
let shown = store.set_code(&file.id, &edit.token).unwrap();
let guesser = ip("198.51.100.9");
for n in 0..WRONG_CODES {
assert_eq!(store.resolve_code(guesser, &code::online(u64::from(n) + 1)).unwrap_err(), NO_CODE, "guess {n}");
}
// Locked out: not even the right code is looked up for that address.
for typed in [code::online(77), shown.clone()] {
let e = store.resolve_code(guesser, &typed).unwrap_err();
assert!(e.contains("Too many wrong codes") && e.contains("15 minutes") && e.contains("invite link"), "{e}");
}
// Its link still joins, and other addresses' codes still work.
assert!(store.admit(&file.id, &edit.token).is_ok());
assert_eq!(store.resolve_code(ip("198.51.100.10"), &shown).unwrap().0, file.id);
// An IPv6 guesser is counted by its /64.
for n in 0..WRONG_CODES {
let from: IpAddr = format!("2001:db8:1:2::{n:x}").parse().unwrap();
let _ = store.resolve_code(figcraft_collab_net::hub::ip_key(from), &code::online(1000 + u64::from(n)));
}
let neighbour = figcraft_collab_net::hub::ip_key(ip("2001:db8:1:2:ffff::1"));
assert!(store.resolve_code(neighbour, &shown).unwrap_err().contains("Too many wrong codes"));
}
#[test]
fn the_limits_on_codes_over_time() {
let t0 = Instant::now();
let me = ip("198.51.100.9");
let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
// A right code forgives the wrong ones before it.
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(me, false, t0);
}
abuse.code_tried(me, true, t0);
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(me, false, t0);
}
assert_eq!(abuse.code_locked(me, t0), None);
// One more: locked for the lockout, then free again.
abuse.code_tried(me, false, t0);
assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT));
assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some());
assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None);
// Wrong codes long apart don't add up.
let other = ip("198.51.100.10");
for n in 0..3 * WRONG_CODES {
abuse.code_tried(other, false, t0 + (CODE_LOCKOUT + Duration::from_secs(1)) * n);
}
assert_eq!(abuse.code_locked(other, t0 + CODE_LOCKOUT * 4 * WRONG_CODES), None);
// Codes tried a minute, right or wrong (these limits hold on private servers too).
let typist = ip("198.51.100.11");
let tried = (0..100).filter(|_| abuse.allow(typist, Action::Code, t0)).count();
assert_eq!(tried as f64, CODES_PER_MINUTE);
assert!(abuse.allow(typist, Action::Code, t0 + Duration::from_secs(7)), "one more every six seconds");
// Everyone together: many addresses guessing pause codes for all (links still join).
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
assert!(abuse.codes_open(t0));
for n in 0..WRONG_CODES_PER_HOUR as u32 {
abuse.code_tried(IpAddr::from([10, 1, (n >> 8) as u8, n as u8]), false, t0);
}
assert!(!abuse.codes_open(t0));
assert!(abuse.codes_open(t0 + Duration::from_secs(60)), "it refills");
}
#[test]
fn everyone_guessing_pauses_codes() {
let dir = TempDir::new("codes-busy");
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
let shown = store.set_code(&file.id, &edit.token).unwrap();
// Junk costs no hashing, so a thousand wrong "codes" from a thousand addresses are quick.
for n in 0..WRONG_CODES_PER_HOUR as u32 {
assert_eq!(store.resolve_code(IpAddr::from([10, 2, (n >> 8) as u8, n as u8]), "not a code").unwrap_err(), NO_CODE);
}
assert_eq!(store.resolve_code(ip("192.0.2.99"), &shown).unwrap_err(), CODES_BUSY);
assert!(store.admit(&file.id, &edit.token).is_ok(), "links are not affected");
}
fn refused(raw: &mut Raw) -> String {
match raw.next() {
ServerMsg::Refused { reason } => reason,
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn junk_requests_are_refused_and_never_hurt_the_server() {
let dir = TempDir::new("codes-junk");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
let url = format!("ws://{addr}/");
let mut a = app("Ada", &url);
ex(&mut a, "collab.share", json!({}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
let ask = |code: &str, protocol: u32| {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::Resolve(Resolve { protocol, client_version: String::new(), code: code.to_string() }));
let why = refused(&mut raw);
assert!(raw.closed_within(Duration::from_secs(5)), "the connection closes after the answer");
why
};
// Junk in place of a code (each from this one address: after ten it is locked out).
for junk in ["", " ", "💥💥💥", "K7M-2QX", "../../etc/passwd", "K7M2QX9TDK7M2QX9TD"] {
assert_eq!(ask(junk, PROTOCOL), NO_CODE, "{junk:?}");
}
assert_eq!(ask(&"Z".repeat(4000), PROTOCOL), NO_CODE);
assert!(ask("K7M2QX9TD", 1).contains("isn't supported"));
// Asking for a code needs the file's owner token (or the key), and one of its link tokens.
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: "AAAAAAAAAAAAAAAAAAAAAA".into() }));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
let mut raw = Raw::connect(addr);
let owner = p.owner.clone().unwrap();
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
for token in ["", "AAAAAAAAAAAAAAAAAAAAAA", &"x".repeat(5000)] {
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: token.to_string() }));
assert!(
matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("isn't one of this file's links")))
);
}
// A code in place of a link's token joins nothing: codes only resolve.
let mut raw = Raw::connect(addr);
let shown = a.live_summary()["code"].as_str().unwrap().replace('-', "");
let hello = Hello {
protocol: PROTOCOL,
file: p.file.clone(),
token: shown,
name: "Mal".into(),
color: String::new(),
client_version: String::new(),
resume: None,
};
raw.send(&ClientMsg::Hello(hello));
assert!(refused(&mut raw).contains("isn't valid"));
// The session went on through all of it.
assert_eq!(a.live_summary()["status"], "live");
server.stop();
}
#[test]
fn clients_from_before_join_codes_are_served_as_before() {
let dir = TempDir::new("codes-old-client");
let mut server = crate::start(&open(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
// An app that never asks for codes (`collab.publish`, what older apps do): links only, and
// nothing about codes is kept for the file.
let mut a = session("Ada");
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
assert_eq!((&a.live_summary()["code"], &a.live_summary()["share"]), (&Value::Null, &Value::Null));
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
assert!(!links.contains("\"code\""), "{links}");
// Its links join as ever.
let mut b = crate::test_util::join(&p.view, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
server.stop();
// A data directory from before join codes (links without the field) opens unchanged.
let old = r#"[{"id":"0123456789abcdef","access":"edit","label":"Published (edit)","created":1,"hash":"00"}]"#;
let path = dir.path().join("files").join(&p.file).join("links.json");
std::fs::write(&path, old).unwrap();
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
assert_eq!(store.links(&p.file).unwrap().len(), 1);
assert_eq!(store.resolve_code(ip("192.0.2.1"), "K7M2QX9TD").unwrap_err(), NO_CODE);
}
+198 -17
View File
@@ -1,12 +1,14 @@
//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
use std::net::SocketAddr;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use figcraft_collab_net::{Endpoint, NetLimits};
use serde::Deserialize;
use crate::store::StoreConfig;
use crate::abuse::AbuseConfig;
use crate::directory::DirectoryConfig;
use crate::store::{HostingConfig, StoreConfig};
/// The port the server listens on unless told otherwise (the app's sessions try it first too).
pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
@@ -25,8 +27,8 @@ pub struct Config {
/// Most files kept.
pub max_files: usize,
/// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
/// most 64 MiB).
pub max_file_mb: u64,
/// most 64 MiB). Unset: 64, or 16 when open to the public ([`Config::file_mb`]).
pub max_file_mb: Option<u64>,
/// Most share links per file.
pub max_links: usize,
/// Most open connections.
@@ -37,6 +39,36 @@ pub struct Config {
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
/// Most plugin versions the plugin directory keeps (0 turns it off).
pub max_plugins: usize,
/// Largest plugin bundle, in MiB.
pub max_plugin_mb: u64,
/// Most MiB of plugin bundles kept.
pub max_plugins_mb: u64,
/// Anyone may publish a file without the server key (and gets an owner token for it): a
/// server open to the public. The limits below then apply per address.
pub open_publish: bool,
/// How to reach the operator (an email address or a web page) and the terms of use (a web
/// address): the app shows them where people publish.
pub operator_contact: String,
pub terms_url: String,
/// The reverse proxy in front of the server (`127.0.0.1` for Caddy or nginx on the same
/// machine): only its `X-Forwarded-For` is believed.
pub trusted_proxy: Option<String>,
/// Files published without the key are deleted after this many days without anyone opening
/// them (0: kept; fractions allowed).
pub idle_days: f64,
/// Publishing without the key stops while the files take this many MiB in all.
pub max_total_mb: u64,
/// Per address, when open: most files published without the key, and their MiB.
pub max_files_per_ip: usize,
pub max_mb_per_ip: u64,
/// Per address, when open: most connections at once, new connections a minute, files
/// published an hour and messages a second.
pub max_connections_per_ip: usize,
pub connects_per_minute: u32,
pub publishes_per_hour: u32,
pub messages_per_second: u32,
}
impl Default for Config {
@@ -46,12 +78,27 @@ impl Default for Config {
data: PathBuf::from("figcraft-data"),
public_url: None,
max_files: 1000,
max_file_mb: 64,
max_file_mb: None,
max_links: 100,
max_connections: 512,
max_people: 256,
compact_after: 1000,
compact_mb: 16,
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
open_publish: false,
operator_contact: String::new(),
terms_url: String::new(),
trusted_proxy: None,
idle_days: 30.0,
max_total_mb: 10_240,
max_files_per_ip: 20,
max_mb_per_ip: 256,
max_connections_per_ip: 32,
connects_per_minute: 30,
publishes_per_hour: 10,
messages_per_second: 200,
}
}
}
@@ -78,26 +125,93 @@ impl Config {
Ok(Some(e))
}
/// The largest file in MiB: as set, else 64, or 16 on a server open to the public (strangers'
/// files stay small unless the operator says otherwise).
pub fn file_mb(&self) -> u64 {
self.max_file_mb.unwrap_or(if self.open_publish { 16 } else { 64 })
}
/// What an operator should hear about these settings at startup (a public server without an
/// abuse contact, or one whose clients would all count as its proxy).
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
if !self.open_publish {
return out;
}
if self.operator_contact.trim().is_empty() {
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
}
let behind_proxy = self.public().ok().flatten().is_some_and(|e| e.secure) || self.addr().is_ok_and(|a| a.ip().is_loopback());
if behind_proxy && self.trusted_proxy.as_deref().is_none_or(|p| p.trim().is_empty()) {
out.push(
"open_publish is on without trusted_proxy: behind a reverse proxy every client counts as the proxy's address, so one person's limits stop everyone; set trusted_proxy = \"127.0.0.1\"".into(),
);
}
if self.idle_days == 0.0 {
out.push("open_publish is on with idle_days = 0: files published without the key are never deleted".into());
}
out
}
/// The trusted proxy's address, when set.
pub fn proxy(&self) -> Result<Option<IpAddr>, String> {
let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) };
p.parse().map(Some).map_err(|_| "`trusted_proxy` must be an IP address, like 127.0.0.1".to_string())
}
/// Check everything (before starting).
pub fn check(&self) -> Result<(), String> {
self.addr()?;
self.public()?;
self.proxy()?;
if !self.idle_days.is_finite() || !(0.0..=3650.0).contains(&self.idle_days) {
return Err("`idle_days` must be from 0 to 3650".into());
}
let terms = self.terms_url.trim();
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
return Err("`terms_url` must be a web address (https://…)".into());
}
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
return Err("`operator_contact` is at most 120 characters and `terms_url` at most 500, on one line".into());
}
for (name, v) in [
("max_files", self.max_files as u64),
("max_file_mb", self.max_file_mb),
("max_links", self.max_links as u64),
("max_connections", self.max_connections as u64),
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_total_mb", self.max_total_mb),
("max_files_per_ip", self.max_files_per_ip as u64),
("max_mb_per_ip", self.max_mb_per_ip),
("max_connections_per_ip", self.max_connections_per_ip as u64),
("connects_per_minute", u64::from(self.connects_per_minute)),
("publishes_per_hour", u64::from(self.publishes_per_hour)),
("messages_per_second", u64::from(self.messages_per_second)),
] {
if v == 0 {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
for (name, v) in [
("max_files", self.max_files as u64),
("max_file_mb", self.file_mb()),
("max_links", self.max_links as u64),
("max_connections", self.max_connections as u64),
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_plugin_mb", self.max_plugin_mb),
("max_plugins_mb", self.max_plugins_mb),
] {
if v == 0 {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.file_mb() > 4096 || self.compact_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.
if self.max_plugin_mb > 32 {
return Err("`max_plugin_mb` is at most 32".into());
}
if self.max_plugins > 10_000 || self.max_plugins_mb > 1 << 20 {
return Err("`max_plugins` is at most 10000 and `max_plugins_mb` at most 1048576".into());
}
Ok(())
}
@@ -105,17 +219,84 @@ impl Config {
pub fn store(&self) -> StoreConfig {
let mib = |n: u64| n.saturating_mul(1 << 20);
StoreConfig {
hosting: HostingConfig {
open: self.open_publish,
operator: self.operator_contact.trim().to_string(),
terms: self.terms_url.trim().to_string(),
// Whole seconds: `check` keeps the days finite and at most 3650.
idle_secs: (self.idle_days * 86_400.0).round() as u64,
max_file_mb: self.file_mb(),
max_total_bytes: mib(self.max_total_mb),
max_files_per_ip: self.max_files_per_ip,
max_bytes_per_ip: mib(self.max_mb_per_ip),
abuse: AbuseConfig {
open: self.open_publish,
connects_per_minute: self.connects_per_minute,
publishes_per_hour: self.publishes_per_hour,
messages_per_second: self.messages_per_second,
},
},
max_files: self.max_files,
max_file_bytes: mib(self.max_file_mb),
max_file_bytes: mib(self.file_mb()),
max_links: self.max_links,
compact_after: self.compact_after,
compact_bytes: mib(self.compact_mb),
core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() },
core: self.core(),
directory: DirectoryConfig {
max_plugins: self.max_plugins,
max_plugin_bytes: mib(self.max_plugin_mb),
max_total_bytes: mib(self.max_plugins_mb),
},
// At least 1 GiB, and room for two of the largest files (a compaction writes one beside
// the old one).
min_free_bytes: mib(self.file_mb().saturating_mul(2)).max(1 << 30),
}
}
/// The transport's part.
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
/// and a margin, not the core's 64 MiB.
fn open_message(&self) -> usize {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
/// 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).
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
if !self.open_publish {
return base;
}
let max = self.open_message().min(base.max_message_bytes);
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 }
}
/// The transport's part. A private server keeps the defaults; open to the public, connections
/// per address are capped, messages are as large as a file at most, the bytes queued for a
/// client and for everyone and those waiting for the hub are bounded, one-shot requests leave
/// an eighth of the connections to sessions and are dropped after 20 idle seconds.
pub fn net(&self) -> NetLimits {
NetLimits { max_connections: self.max_connections, ..NetLimits::default() }
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
if !self.open_publish {
return base;
}
let max = self.open_message().min(base.max_client_message);
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
// eighth of each, and its biggest consumers are dropped first.
let (queued, reading) = (256usize << 20, 192usize << 20);
NetLimits {
max_per_address: self.max_connections_per_ip,
max_client_message: max,
max_queued_bytes: max.saturating_add(1 << 20),
max_queued_block: queued / 8,
max_queued_total: queued,
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
max_reading_total: reading,
max_in_flight: 128 << 20,
reserve_for_sessions: self.max_connections / 8,
request_idle: std::time::Duration::from_secs(20),
..base
}
}
}
+298
View File
@@ -0,0 +1,298 @@
//! The plugin directory: plugin bundles (`figcraft_plugin::bundle`) published to the server with
//! its key, listed, searched and fetched by anyone who can reach it, and unpublished with the key
//! (there are no accounts: whoever has the key is the publisher, and the operator moderates).
//!
//! ```text
//! <data>/plugins/index.json every version kept: manifest fields, size, SHA-256, when
//! <data>/plugins/<sha256>.bin a bundle, named by the SHA-256 of its bytes
//! ```
//!
//! A bundle is checked in full before it is kept (it decodes within the size cap, its manifest
//! parses and asks for a plugin API this build runs, its version is newer than the one kept, its
//! module compiles and imports only what the sandbox provides, its asset names are plain) and is
//! never run here. Bundles are named by their hash, so no name a publisher picks reaches the file
//! system, and a fetch reads the file back and checks its hash before sending it. Caps: bundle
//! size, versions kept and bytes kept; fetches share a budget of bytes per second, so a crowd of
//! downloads can't queue more than a bounded amount of data.
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use figcraft_collab::message::{PluginEntry, PluginReply, PluginReq};
use figcraft_collab_net::net::Bucket;
use figcraft_plugin::bundle::{Bundle, compare_versions, parse_version, sha256_hex};
use figcraft_plugin::manifest::Access;
use crate::disk;
use crate::store::now;
pub const PLUGINS: &str = "plugins";
pub const INDEX: &str = "index.json";
/// Largest `index.json` read, longest search, and most bytes of entries one listing carries.
const MAX_INDEX_FILE: u64 = 64 << 20;
const MAX_QUERY: usize = 200;
const MAX_LISTING: usize = 32 << 20;
/// What fetches may send, in MiB per second on average (each costs at least one).
const SERVE_MIB_PER_SEC: f64 = 32.0;
/// …and at once: eight times what one block of addresses may download at once, so no block can
/// take it all (see `abuse`).
const SERVE_BURST_MIB: f64 = 512.0;
const MIB: f64 = (1 << 20) as f64;
/// What the directory enforces.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DirectoryConfig {
/// Most versions kept (all plugins together); 0 turns the directory off.
pub max_plugins: usize,
/// Largest bundle.
pub max_plugin_bytes: u64,
/// Most bytes of bundles kept.
pub max_total_bytes: u64,
}
/// The plugins a server keeps.
pub struct Directory {
dir: PathBuf,
cfg: DirectoryConfig,
entries: Vec<PluginEntry>,
served: Bucket,
/// `index.json` couldn't be read: nothing is written over it until the operator fixes it.
broken: bool,
}
/// A plugin id as manifests have them (letters, digits, `.`, `-`, `_`; 1–128).
fn valid_id(s: &str) -> bool {
(1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
}
fn valid_sha(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Newest first.
fn by_version(a: &PluginEntry, b: &PluginEntry) -> Ordering {
compare_versions(&b.version, &a.version).unwrap_or(Ordering::Equal)
}
impl Directory {
/// The directory under `root` (made when the first plugin is published). Entries whose bundle
/// is missing or the wrong size are left out and reported, never deleted.
pub fn open(root: &Path, cfg: DirectoryConfig) -> (Directory, Vec<String>) {
let dir = root.join(PLUGINS);
let burst = (2 * cfg.max_plugin_bytes) as f64 / MIB;
let mut d = Directory { dir, cfg, entries: Vec::new(), served: Bucket::new(SERVE_MIB_PER_SEC, burst.max(SERVE_BURST_MIB)), broken: false };
let mut problems = Vec::new();
let index = d.dir.join(INDEX);
let _ = fs::remove_file(disk::temp_of(&index));
if !index.exists() {
return (d, problems);
}
let all: Vec<PluginEntry> =
match disk::read_capped(&index, MAX_INDEX_FILE).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())) {
Ok(all) => all,
Err(e) => {
problems.push(format!("plugin directory: {INDEX} can't be read ({e}); no plugins are listed or published until it is fixed"));
d.broken = true;
return (d, problems);
}
};
for e in all {
let size = fs::metadata(d.file(&e.sha256)).map(|m| m.len()).ok();
if !valid_id(&e.id) || parse_version(&e.version).is_none() || !valid_sha(&e.sha256) || size != Some(e.bytes) {
problems.push(format!(
"plugin directory: skipped {} {}: its bundle is missing or damaged",
e.id.escape_debug(),
e.version.escape_debug()
));
continue;
}
d.entries.push(e);
}
(d, problems)
}
fn file(&self, sha: &str) -> PathBuf {
self.dir.join(format!("{sha}.bin"))
}
/// Every version kept.
pub fn entries(&self) -> &[PluginEntry] {
&self.entries
}
/// Bytes of bundles kept (a bundle published under two versions counts once).
fn total_bytes(&self) -> u64 {
let files: BTreeMap<&str, u64> = self.entries.iter().map(|e| (e.sha256.as_str(), e.bytes)).collect();
files.values().sum()
}
/// Answer one request; `publisher`: the connection has the server's key.
pub fn handle(&mut self, publisher: bool, req: PluginReq) -> PluginReply {
let failed = |reason: String| PluginReply::Failed { reason };
if self.cfg.max_plugins == 0 {
return failed("this server's plugin directory is turned off".into());
}
let needs_key = || failed("publishing and unpublishing plugins needs the server's key (ws://host:port/?key=…)".into());
match req {
PluginReq::List { query } => PluginReply::List(self.list(&query)),
PluginReq::Fetch { id, version } => match self.fetch(&id, version.as_deref()) {
Ok((entry, bundle)) => PluginReply::Bundle { entry, bundle },
Err(e) => failed(e),
},
PluginReq::Publish { .. } | PluginReq::Unpublish { .. } if !publisher => needs_key(),
PluginReq::Publish { bundle } => self.publish(&bundle).map_or_else(failed, PluginReply::Published),
PluginReq::Unpublish { id, version } => self.unpublish(&id, version.as_deref()).map_or_else(failed, PluginReply::Removed),
}
}
/// The latest version of each plugin matching `query` (any case, in its id, name, description
/// or author), by name, with the versions kept.
pub fn list(&self, query: &str) -> Vec<PluginEntry> {
let q: String = query.trim().chars().take(MAX_QUERY).collect::<String>().to_lowercase();
let mut by_id: BTreeMap<&str, Vec<&PluginEntry>> = BTreeMap::new();
for e in &self.entries {
by_id.entry(e.id.as_str()).or_default().push(e);
}
let mut out: Vec<PluginEntry> = by_id
.into_values()
.filter_map(|mut versions| {
versions.sort_by(|a, b| by_version(a, b));
let mut latest = (*versions.first()?).clone();
latest.versions = versions.iter().map(|v| v.version.clone()).collect();
Some(latest)
})
.filter(|e| q.is_empty() || [&e.id, &e.name, &e.description, &e.author].iter().any(|f| f.to_lowercase().contains(&q)))
.collect();
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id)));
// A listing stays well within a message.
let mut size = 0;
out.retain(|e| {
size += 256 + e.name.len() + e.description.len() + e.author.len() + e.commands.iter().map(|c| c.len() + 8).sum::<usize>();
size <= MAX_LISTING
});
out
}
/// The size of version `version` (or the latest) of plugin `id`'s bundle, without reading it.
pub fn size_of(&self, id: &str, version: Option<&str>) -> Option<u64> {
self.entries
.iter()
.filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)))
.min_by(|a, b| by_version(a, b))
.map(|e| e.bytes)
}
/// Version `version` (or the latest) of plugin `id`, and its bundle, read back and checked
/// against its hash.
pub fn fetch(&mut self, id: &str, version: Option<&str>) -> Result<(PluginEntry, Vec<u8>), String> {
let entry = self
.entries
.iter()
.filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)))
.min_by(|a, b| by_version(a, b))
.cloned()
.ok_or_else(|| match version {
Some(v) => format!("the directory has no version {} of that plugin", v.chars().take(32).collect::<String>()),
None => "the directory has no such plugin (it may have been unpublished)".to_string(),
})?;
if !self.served.take_n(Instant::now(), 1.0 + entry.bytes as f64 / MIB) {
return Err("the server is busy sending plugins: try again in a moment".into());
}
let bytes = disk::read_capped(&self.file(&entry.sha256), self.cfg.max_plugin_bytes.max(entry.bytes))?;
if sha256_hex(&bytes) != entry.sha256 {
log::error!("plugin directory: the bundle of {} {} is damaged on disk; not sending it", entry.id, entry.version);
return Err("that plugin's bundle is damaged on the server; tell the server's owner".into());
}
Ok((entry, bytes))
}
/// Keep a bundle as a new version of its plugin, after checking all of it.
// shortcut: the bundle is checked (its module compiled) on the hub thread, like team libraries;
// move it to a worker if large plugins make other sessions wait noticeably.
pub fn publish(&mut self, bytes: &[u8]) -> Result<PluginEntry, String> {
let max = self.cfg.max_plugin_bytes;
if bytes.len() as u64 > max {
return Err(format!("the plugin is {} KiB; this server keeps plugins up to {} KiB", bytes.len() >> 10, max >> 10));
}
let bundle = Bundle::decode(bytes, usize::try_from(max).unwrap_or(usize::MAX))?;
let m = bundle.check()?;
if let Some(latest) = self.entries.iter().filter(|e| e.id == m.id).min_by(|a, b| by_version(a, b))
&& compare_versions(&m.version, &latest.version) != Some(Ordering::Greater)
{
return Err(format!("version {} of {} is on the server already: publish a newer version", latest.version, m.id));
}
if self.entries.len() >= self.cfg.max_plugins {
return Err(format!("the directory keeps at most {} plugin versions: unpublish some first", self.cfg.max_plugins));
}
let sha = sha256_hex(bytes);
let known = self.entries.iter().any(|e| e.sha256 == sha);
if !known && self.total_bytes() + bytes.len() as u64 > self.cfg.max_total_bytes {
return Err(format!("the directory is full ({} MiB): unpublish some plugins first", self.cfg.max_total_bytes >> 20));
}
let entry = PluginEntry {
id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
author: m.author.clone(),
api: m.api,
access: match m.permissions.access {
Access::Read => "read".into(),
Access::Write => "write".into(),
},
commands: m.permissions.commands.clone(),
widget: m.widget,
bytes: bytes.len() as u64,
sha256: sha.clone(),
published: now(),
versions: Vec::new(),
};
fs::create_dir_all(&self.dir).map_err(|e| format!("couldn't keep the plugin: {e}"))?;
let file = self.file(&sha);
if !known {
disk::write_atomic(&file, bytes).map_err(|e| format!("couldn't keep the plugin: {e}"))?;
}
self.entries.push(entry.clone());
if let Err(e) = self.save() {
self.entries.pop();
return Err(format!("couldn't keep the plugin: {e}"));
}
log::info!("plugin directory: published {} {} ({} bytes)", entry.id, entry.version, entry.bytes);
Ok(entry)
}
/// Remove version `version` (or every version) of plugin `id`; the versions removed.
pub fn unpublish(&mut self, id: &str, version: Option<&str>) -> Result<Vec<String>, String> {
let gone = |e: &PluginEntry| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal));
let (removed, kept): (Vec<PluginEntry>, Vec<PluginEntry>) = self.entries.iter().cloned().partition(gone);
if removed.is_empty() {
return Err("the directory has no such plugin or version".into());
}
let before = std::mem::replace(&mut self.entries, kept);
if let Err(e) = self.save() {
self.entries = before;
return Err(format!("couldn't unpublish: {e}"));
}
for r in &removed {
if !self.entries.iter().any(|e| e.sha256 == r.sha256)
&& let Err(e) = fs::remove_file(self.file(&r.sha256))
{
log::warn!("plugin directory: couldn't delete the bundle of {} {}: {e}", r.id, r.version);
}
}
let versions: Vec<String> = removed.into_iter().map(|e| e.version).collect();
log::info!("plugin directory: unpublished {id} {}", versions.join(", "));
Ok(versions)
}
fn save(&self) -> Result<(), String> {
if self.broken {
return Err(format!("the directory's {INDEX} is damaged; tell the server's owner"));
}
let text = serde_json::to_vec_pretty(&self.entries).map_err(|e| e.to_string())?;
disk::write_atomic(&self.dir.join(INDEX), &text).map_err(|e| e.to_string())
}
}
+264
View File
@@ -0,0 +1,264 @@
//! The plugin directory: publishing (checked in full, key only), listing and searching, fetching
//! (hash-checked, within a budget), unpublishing and its caps in the store; then end to end over
//! loopback: the app's commands publish a plugin folder, another session browses without the key,
//! installs with confirmation, runs, updates and removes it; the raw protocol refuses wrong keys and
//! stray messages; and the server's command line lists and moderates.
use std::path::Path;
use figcraft_collab::message::{ClientMsg, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg};
use figcraft_plugin::bundle::{Asset, Bundle, sha256_hex};
use serde_json::json;
use crate::cli::{self, Cli, Command};
use crate::directory::{DirectoryConfig, INDEX, PLUGINS};
use crate::store::{Store, StoreConfig};
use crate::test_util::{Raw, TempDir, config, key, session};
pub(crate) fn wasm() -> Vec<u8> {
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap()
}
pub(crate) fn manifest(id: &str, version: &str) -> String {
json!({"id": id, "name": "Tiny tool", "version": version, "author": "Ada Example", "description": "Does very little.", "main": "tiny.wasm",
"permissions": {"access": "write", "commands": ["shape.rectangle"]}})
.to_string()
}
fn bundle(id: &str, version: &str) -> Vec<u8> {
Bundle { manifest: manifest(id, version), wasm: wasm(), assets: vec![Asset { name: "icon.png".into(), data: vec![7; 10] }] }.encode()
}
fn cfg(dir: &Path, f: impl Fn(&mut DirectoryConfig)) -> StoreConfig {
let mut c = config(dir).store();
f(&mut c.directory);
c
}
const ID: &str = "org.example.tiny";
#[test]
fn publish_list_fetch_unpublish_and_restart() {
let dir = TempDir::new("plugins-store");
let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
let d = store.directory();
let first = d.publish(&bundle(ID, "1.0.0")).unwrap();
assert_eq!((first.name.as_str(), first.author.as_str(), first.access.as_str()), ("Tiny tool", "Ada Example", "write"));
assert_eq!(first.commands, ["shape.rectangle"]);
assert_eq!(first.sha256, sha256_hex(&bundle(ID, "1.0.0")));
assert!(d.publish(&bundle(ID, "1.0.0")).unwrap_err().contains("newer"));
assert!(d.publish(&bundle(ID, "0.9")).unwrap_err().contains("newer"));
d.publish(&bundle(ID, "1.10.0")).unwrap();
d.publish(&bundle("org.example.other", "2.0.0")).unwrap();
let all = d.list("");
assert_eq!(all.len(), 2);
let tiny = all.iter().find(|e| e.id == ID).unwrap();
assert_eq!((tiny.version.as_str(), tiny.versions.clone()), ("1.10.0", vec!["1.10.0".to_string(), "1.0.0".to_string()]));
assert_eq!(d.list("ADA EXAMPLE").len(), 2, "search covers the author, in any case");
assert!(d.list("nothing like it").is_empty());
let (e, bytes) = d.fetch(ID, None).unwrap();
assert_eq!((e.version.as_str(), sha256_hex(&bytes)), ("1.10.0", e.sha256.clone()));
assert_eq!(d.fetch(ID, Some("1.0")).unwrap().0.version, "1.0.0");
assert!(d.fetch(ID, Some("3.0.0")).is_err() && d.fetch("org.nope", None).is_err());
// Unpublishing one version deletes its bundle.
let file = dir.path().join(PLUGINS).join(format!("{}.bin", first.sha256));
assert!(file.exists());
assert_eq!(d.unpublish(ID, Some("1.0.0")).unwrap(), ["1.0.0"]);
assert!(!file.exists());
assert!(d.unpublish(ID, Some("1.0.0")).is_err());
drop(store);
// Kept across a restart; a bundle damaged on disk is never sent, a missing one is skipped.
let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
assert_eq!(store.directory().entries().len(), 2);
let latest = store.directory().fetch(ID, None).unwrap().0;
let path = dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256));
let mut damaged = std::fs::read(&path).unwrap();
let last = damaged.len() - 1;
damaged[last] ^= 0xFF;
std::fs::write(&path, &damaged).unwrap();
assert!(store.directory().fetch(ID, None).unwrap_err().contains("damaged"));
std::fs::write(&path, b"short").unwrap();
drop(store);
let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
assert!(store.problems().iter().any(|p| p.contains(ID) && p.contains("damaged")), "{:?}", store.problems());
drop(store);
// A damaged index lists nothing but deletes nothing.
std::fs::write(dir.path().join(PLUGINS).join(INDEX), b"{not json").unwrap();
let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
assert!(store.problems().iter().any(|p| p.contains(INDEX)));
let mut store = store;
assert!(store.directory().publish(&bundle(ID, "9.0.0")).unwrap_err().contains("damaged"), "nothing is written over a damaged index");
assert_eq!(std::fs::read(dir.path().join(PLUGINS).join(INDEX)).unwrap(), b"{not json");
assert!(dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256)).exists());
}
#[test]
fn bad_bundles_caps_and_keys() {
let dir = TempDir::new("plugins-bad");
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugin_bytes = 64 << 10)).unwrap();
let d = store.directory();
let with = |f: &dyn Fn(&mut Bundle)| {
let mut b = Bundle::decode(&bundle(ID, "1.0.0"), usize::MAX).unwrap();
f(&mut b);
b.encode()
};
let wasi = wat::parse_str(r#"(module (import "wasi_snapshot_preview1" "proc_exit" (func (param i32))) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
for (bytes, why) in [
(b"PK\x03\x04 a zip".to_vec(), "isn't"),
(vec![0u8; 70 << 10], "keeps plugins up to"),
(with(&|b| b.manifest = "{}".into()), "manifest"),
(with(&|b| b.manifest = manifest(ID, "1.0.0").replace("\"tiny.wasm\"", "\"tiny.wasm\", \"api\": 99")), "API"),
(with(&|b| b.manifest = manifest(ID, "latest")), "version"),
(with(&|b| b.wasm = b"not wasm".to_vec()), "WebAssembly"),
(with(&|b| b.wasm = wasi.clone()), "imports"),
(with(&|b| b.assets[0].name = "../../server.key".into()), "asset"),
(with(&|b| b.assets[0].name = "aux.png".into()), "asset"),
(with(&|b| b.assets[0].data = vec![0; 70 << 10]), "keeps plugins up to"),
] {
let e = d.publish(&bytes).unwrap_err();
assert!(e.contains(why), "{why}: {e}");
}
assert!(d.entries().is_empty());
// Only with the key.
assert!(matches!(d.handle(false, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Failed { reason } if reason.contains("key")));
assert!(matches!(d.handle(false, PluginReq::Unpublish { id: ID.into(), version: None }), PluginReply::Failed { .. }));
assert!(matches!(d.handle(true, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Published(_)));
assert!(matches!(d.handle(false, PluginReq::List { query: "x".repeat(100_000) }), PluginReply::List(_)));
// Fetches share a budget: a flood is turned away, not queued.
let busy = (0..800).filter_map(|_| d.fetch(ID, None).err()).filter(|e| e.contains("busy")).count();
assert!(busy > 0, "a flood of fetches hits the budget");
drop(store);
// Caps: versions kept, bytes kept, off.
let dir = TempDir::new("plugins-caps");
let size = bundle(ID, "1.0.0").len() as u64;
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 2)).unwrap();
store.directory().publish(&bundle(ID, "1.0.0")).unwrap();
store.directory().publish(&bundle(ID, "1.0.1")).unwrap();
assert!(store.directory().publish(&bundle(ID, "1.0.2")).unwrap_err().contains("at most 2"));
drop(store);
let dir = TempDir::new("plugins-full");
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_total_bytes = size + 10)).unwrap();
store.directory().publish(&bundle(ID, "1.0.0")).unwrap();
assert!(store.directory().publish(&bundle(ID, "1.0.1")).unwrap_err().contains("full"));
drop(store);
let dir = TempDir::new("plugins-off");
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 0)).unwrap();
assert!(
matches!(store.directory().handle(true, PluginReq::List { query: String::new() }), PluginReply::Failed { reason } if reason.contains("off"))
);
assert!(!dir.path().join(PLUGINS).exists());
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
/// A plugin folder: manifest, module in a subfolder, an asset.
fn folder(root: &Path, version: &str) -> std::path::PathBuf {
let dir = root.join(format!("tiny-{version}"));
std::fs::create_dir_all(dir.join("assets")).unwrap();
std::fs::write(dir.join("figcraft-plugin.json"), manifest(ID, version)).unwrap();
std::fs::write(dir.join("tiny.wasm"), wasm()).unwrap();
std::fs::write(dir.join("assets").join("icon.png"), [1, 2, 3]).unwrap();
dir
}
#[test]
fn publish_browse_install_update_and_remove_over_the_network() {
let dir = TempDir::new("plugins-net");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let with_key = format!("ws://{addr}/?key={}", key(dir.path()));
let plain = format!("ws://{addr}/");
// The publisher: the server's key.
let mut publisher = session("Ada");
let e = publisher.execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.0"), "server": plain})).unwrap_err().to_string();
assert!(e.contains("key"), "{e}");
let r = ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.0"), "server": with_key}));
assert_eq!(r["published"]["version"], "1.0.0");
let wrong = format!("ws://{addr}/?key=0123456789abcdef0123");
assert!(
publisher
.execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.1"), "server": wrong}))
.unwrap_err()
.to_string()
.contains("wrong server key")
);
// A user: browses without the key, installs only with confirmation, runs it.
let mut user = session("Bo");
let listed = ex(&mut user, "plugin.directory.list", json!({"server": plain}));
let p = &listed["plugins"][0];
assert_eq!((p["id"].as_str(), p["access"].as_str(), p["installed"].as_str()), (Some(ID), Some("write"), None));
assert!(user.execute("plugin.directory.install", &json!({"id": ID})).is_err());
let sha = p["sha256"].as_str().unwrap().to_string();
let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha}));
assert_eq!((r["version"].as_str(), r["source"].as_str()), (Some("1.0.0"), Some(plain.as_str())));
ex(&mut user, "plugin.run", json!({"id": ID}));
// An update appears and installs over it; browsing users can't unpublish.
ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.1"), "server": with_key}));
let listed = ex(&mut user, "plugin.directory.list", json!({}));
assert_eq!((listed["plugins"][0]["version"].as_str(), listed["plugins"][0]["update"].as_bool()), (Some("1.0.1"), Some(true)));
let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true}));
assert_eq!((r["version"].as_str(), r["replaced"].as_bool()), (Some("1.0.1"), Some(true)));
assert!(user.execute("plugin.directory.unpublish", &json!({"id": ID, "server": plain})).unwrap_err().to_string().contains("key"));
ex(&mut user, "plugin.remove", json!({"id": ID}));
let r = ex(&mut publisher, "plugin.directory.unpublish", json!({"id": ID, "server": with_key}));
assert_eq!(r["removed"], json!(["1.0.0", "1.0.1"]));
assert!(ex(&mut user, "plugin.directory.list", json!({}))["plugins"].as_array().unwrap().is_empty());
server.stop();
}
#[test]
fn the_raw_protocol_refuses_wrong_keys_and_stray_messages() {
let dir = TempDir::new("plugins-raw");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let open = |key: &str| ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: key.into(), client_version: String::new() });
let answer = |raw: &mut Raw| match raw.next() {
ServerMsg::Plugins(r) => *r,
other => panic!("not a directory answer: {other:?}"),
};
let mut raw = Raw::connect(addr);
raw.send(&open("not the key"));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("wrong server key")));
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL + 1, key: String::new(), client_version: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("protocol")));
let mut raw = Raw::connect(addr);
raw.send(&open(""));
assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: false });
raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") }));
assert!(matches!(answer(&mut raw), PluginReply::Failed { reason } if reason.contains("key")));
raw.send(&ClientMsg::Plugins(PluginReq::Fetch { id: "../../server.key".into(), version: Some("..".into()) }));
assert!(matches!(answer(&mut raw), PluginReply::Failed { .. }));
raw.send(&ClientMsg::Ping);
assert_eq!(raw.next(), ServerMsg::Pong);
raw.send(&ClientMsg::Admin(figcraft_collab::message::Admin::Files));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("plugin directory")));
let mut raw = Raw::connect(addr);
raw.send(&open(&key(dir.path())));
assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: true });
raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") }));
assert!(matches!(answer(&mut raw), PluginReply::Published(e) if e.id == ID));
server.stop();
}
#[test]
fn the_command_line_lists_and_moderates() {
let dir = TempDir::new("plugins-cli");
let run = |command: Command| cli::run(&Cli { config: config(dir.path()), command });
assert!(run(Command::Plugins).unwrap().contains("No plugins yet"));
Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle(ID, "1.0.0")).unwrap();
let table = run(Command::Plugins).unwrap();
assert!(table.contains(ID) && table.contains("Ada Example") && table.contains("write"), "{table}");
// Through the running server too.
let mut server = crate::start(&config(dir.path())).unwrap();
assert!(run(Command::Plugins).unwrap().contains(ID));
assert!(run(Command::RemovePlugin { id: ID.into(), version: Some("2.0.0".into()) }).is_err());
assert!(run(Command::RemovePlugin { id: ID.into(), version: None }).unwrap().contains("Unpublished"));
server.stop();
assert!(run(Command::Plugins).unwrap().contains("No plugins yet"));
let parsed = cli::parse(&["plugins".to_string(), "remove".into(), ID.into(), "--version".into(), "1.0.0".into()]).unwrap();
assert_eq!(parsed.command, Command::RemovePlugin { id: ID.into(), version: Some("1.0.0".into()) });
assert_eq!(cli::parse(&["--version".to_string()]).unwrap().command, Command::Version);
}
+32 -1
View File
@@ -32,6 +32,19 @@ const SNAP_HEADER: usize = 4 + 1 + 8 + 4;
pub const LOG_HEADER: usize = 4 + 1 + 8;
const RECORD_HEADER: usize = 8;
/// Free space on the disk holding `path`, in bytes, where it can be told (Unix).
pub fn free_bytes(path: &Path) -> Option<u64> {
#[cfg(unix)]
{
rustix::fs::statvfs(path).ok().map(|s| s.f_bavail.saturating_mul(s.f_frsize))
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
/// `path` with `.tmp` added: where its next version is written first.
pub fn temp_of(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
@@ -41,9 +54,27 @@ pub fn temp_of(path: &Path) -> PathBuf {
/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_as(path, bytes, false)
}
/// [`write_atomic`] for a secret: on Unix only the owner can read it, from the moment it exists.
pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_as(path, bytes, true)
}
fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
let tmp = temp_of(path);
{
let mut f = File::create(&tmp)?;
let mut o = fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
if private {
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
#[cfg(not(unix))]
let _ = private;
let mut f = o.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
+840
View File
@@ -0,0 +1,840 @@
//! Open hosting against resource exhaustion: each test reproduces an attack from the security
//! review and checks what bounds it — a zip-bomb library (H1), a client that never reads its
//! replies (H2), a file growing past its cap through batches and a log that can't be written (H3),
//! usage reports that rewrote a file per request (M1), oversized messages and a busy hub (M2),
//! many addresses from one IPv6 block and one-shot requests taking every connection (M3), revoked
//! links on library connections (M4), data directory permissions (L6) and downloads per address
//! (L7).
use std::io::{Cursor, Write};
use std::net::{IpAddr, TcpListener};
use std::time::{Duration, Instant};
use figcraft_collab::message::{
Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReq, ServerMsg, Usage,
};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Client, Limits, NoRecompute, Outgoing, Replica, Server};
use figcraft_collab_net::hub::{Backend, Hosting};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_engine::Session;
use serde_json::json;
use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST};
use crate::config::Config;
use crate::public_tests::via_proxy;
use crate::store::{BUSY_DOWNLOADS, NO_PUBLIC_LIBRARY, Store};
use crate::test_util::{Raw, TempDir, config};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "[email protected]".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
/// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot
/// (deflated: a few KiB) and `padding` incompressible bytes stored beside it.
fn archive(bomb: usize, padding: usize) -> Vec<u8> {
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
s.execute("component.create", &json!({"ids": [f]})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let lib = s.doc().unwrap().published.clone().unwrap();
let bytes = figcraft_engine::library_server::archive(&lib).unwrap();
if bomb == 0 && padding == 0 {
return bytes;
}
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..src.len() {
let f = src.by_index_raw(i).unwrap();
if bomb > 0 && f.name() == "library/document.json" {
continue;
}
out.raw_copy_file(f).unwrap();
}
let opts = |m| zip::write::SimpleFileOptions::default().compression_method(m);
if bomb > 0 {
out.start_file("library/document.json", opts(zip::CompressionMethod::Deflated)).unwrap();
let chunk = vec![b' '; 1 << 20];
for _ in 0..bomb >> 20 {
out.write_all(&chunk).unwrap();
}
}
if padding > 0 {
out.start_file("padding.bin", opts(zip::CompressionMethod::Stored)).unwrap();
let mut rng = Rng::new(3);
out.write_all(&(0..padding).map(|_| rng.next_u64() as u8).collect::<Vec<u8>>()).unwrap();
}
out.finish().unwrap().into_inner()
}
fn open_library(link: &Invite) -> ClientMsg {
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: link.file.clone(), token: link.token.clone(), client_version: String::new() })
}
fn hosting() -> ClientMsg {
ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() })
}
/// Poll `f` until it holds or `secs` pass (bounded: never a fixed sleep for the outcome).
fn until(secs: u64, what: &str, mut f: impl FnMut() -> bool) {
let end = Instant::now() + Duration::from_secs(secs);
while !f() {
assert!(Instant::now() < end, "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(10));
}
}
/// A client in `server` (as the hub would let it in).
fn join(server: &mut Server) -> Client {
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
let (_, out) = server.join_as(h, Access::Edit).unwrap();
let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() };
Client::join(w).unwrap()
}
/// Rename the first page through `c`, as the server takes it.
fn rename(server: &mut Server, c: &mut Client, name: &str) {
let mut d = c.document().clone();
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().name = name.into();
let m = c.edit(&d).unwrap().unwrap();
for o in server.receive(c.id(), m) {
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
c.receive(m, &NoRecompute).unwrap();
}
}
}
// H1 -------------------------------------------------------------------------------------------
#[test]
fn a_zip_bomb_library_is_refused_before_it_unpacks() {
let dir = TempDir::new("bomb");
let mut c = config(dir.path());
c.max_file_mb = Some(1);
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (keyed, ..) = store.publish("Library", &snapshot("Library")).unwrap();
let bomb = archive(64 << 20, 0);
assert!(bomb.len() < 1 << 20, "a small archive ({} bytes) that unpacks to 64 MiB", bomb.len());
let e = store.publish_library(&keyed.id, &bomb).unwrap_err();
assert!(e.contains("too large"), "{e}");
assert_eq!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap(), 1, "a real library still goes in");
drop(store);
// Open to the public: a file published without the key holds no library at all.
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
let (mine, ..) = store.publish_open(ip("203.0.113.5"), "Mine", &snapshot("Mine")).unwrap();
assert_eq!(store.publish_library(&mine.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY);
assert_ne!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY, "the operator's files still can");
}
// H2 -------------------------------------------------------------------------------------------
#[test]
fn a_client_that_never_reads_can_queue_only_so_much() {
let dir = TempDir::new("noread");
let cfg = Config { max_file_mb: Some(2), max_plugin_mb: 1, ..open(dir.path()) };
let cap = cfg.net().max_queued_bytes;
assert_eq!(cap, 4 << 20, "the largest message and a margin");
let view = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 1_500_000)).unwrap();
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
};
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&open_library(&view));
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Ready { .. })));
// Ask for the 1.5 MB library over and over without reading an answer.
for _ in 0..100 {
if raw.try_send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None })).is_err() {
break;
}
}
let mut most = 0;
let end = Instant::now() + Duration::from_secs(3);
while Instant::now() < end {
most = most.max(server.queued_bytes());
std::thread::sleep(Duration::from_millis(5));
}
assert!(most <= cap, "{most} bytes were queued for one client (cap {cap})");
// Its queue is let go once it is dropped.
until(15, "the stuck client's queue to be freed", || server.queued_bytes() == 0);
server.stop();
}
// H3 -------------------------------------------------------------------------------------------
#[test]
fn a_file_cant_grow_past_its_cap_through_batches() {
let dir = TempDir::new("grow");
let cfg = Config { max_file_mb: Some(1), max_plugin_mb: 1, ..open(dir.path()) };
let max = 1u64 << 20;
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let (info, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Grow".into(), snapshot: snapshot("Grow") }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(info.open);
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
let mut raw = Raw::connect(addr);
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
let welcome = loop {
if let w @ ServerMsg::Welcome { .. } = raw.next() {
break w;
}
};
let mut client = Client::join(welcome).unwrap();
// Each batch adds a layer with a 200 KB name: the file grows until the server refuses.
let mut refused = None;
for i in 0..12 {
let mut d = client.document().clone();
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, &format!("{i}{}", "x".repeat(200_000)), 0.0, 0.0, 10.0, 10.0).unwrap();
let Some(m) = client.edit(&d).unwrap() else { continue };
raw.send(&m);
let answer = loop {
if let m @ (ServerMsg::Changes(_) | ServerMsg::Reject { .. }) = raw.next() {
break m;
}
};
if let ServerMsg::Reject { reason, .. } = &answer {
refused = Some(reason.clone());
}
client.receive(answer, &NoRecompute).unwrap();
if refused.is_some() {
break;
}
}
let reason = refused.expect("the server refused a batch once the file was full");
assert!(reason.contains("reached the server's limit of 1 MiB"), "{reason}");
let on_disk: u64 = ["snapshot.bin", "log.bin"]
.iter()
.filter_map(|n| std::fs::metadata(dir.path().join("files").join(&file.id).join(n)).ok())
.map(|m| m.len())
.sum();
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
drop(raw);
server.stop();
// Everything confirmed opens again with the same settings, after compacting on the way out.
let kept = client.document().nodes().count();
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
let welcome = loop {
match raw.next() {
w @ ServerMsg::Welcome { .. } => break w,
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
_ => {}
}
};
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
server.stop();
}
#[test]
fn the_sessions_memory_of_batches_is_bounded_in_bytes() {
let mut server = Server::new(Document::new("Log"), "", Limits { max_log_bytes: 100_000, ..Limits::default() });
let mut c = join(&mut server);
for i in 0..20 {
rename(&mut server, &mut c, &format!("{i}{}", "y".repeat(30_000)));
assert!(server.log_bytes() <= 100_000, "{} bytes kept", server.log_bytes());
}
assert!(server.log_bytes() > 0, "recent batches are still kept");
}
#[test]
fn changes_that_cant_be_saved_are_never_confirmed() {
let dir = TempDir::new("unsaved");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (info, ..) = store.publish("Durable", &snapshot("Durable")).unwrap();
let mut server = Backend::open(&mut store, &info.id).unwrap();
let mut c = join(&mut server);
rename(&mut server, &mut c, "Kept");
Backend::changed(&mut store, &info.id, &mut server);
assert!(Backend::flush(&mut store).is_empty());
rename(&mut server, &mut c, "Lost");
Backend::changed(&mut store, &info.id, &mut server);
store.break_log(&info.id);
assert_eq!(Backend::flush(&mut store), vec![info.id.clone()], "the hub hears it couldn't be saved");
// The file opens again from what is on disk: the saved change, not the unsaved one.
drop(server);
let mut server = Backend::open(&mut store, &info.id).unwrap();
let c = join(&mut server);
let page = c.document().first_page().unwrap();
assert_eq!(c.document().node(page).unwrap().name, "Kept");
}
// M1 -------------------------------------------------------------------------------------------
#[test]
fn usage_reports_dont_rewrite_a_file_per_request() {
let dir = TempDir::new("usage");
let cfg = config(dir.path());
let view = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 0)).unwrap();
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
};
let usage_file = dir.path().join("files").join(&view.file).join("usage.json");
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&open_library(&view));
raw.next();
for i in 0..50u32 {
let usage = Usage { file: format!("User {}", i % 7), components: vec![(NodeId(2), i + 1)] };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(1), usage: Some(usage) }));
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Unchanged { .. })));
}
assert!(!usage_file.exists(), "kept in memory, not written 50 times");
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let ServerMsg::Library(r) = raw.next() else { panic!() };
let LibraryReply::Usage(files) = *r else { panic!() };
assert_eq!(files.len(), 7);
server.stop();
assert!(usage_file.exists(), "written when the server stops");
}
// M2 -------------------------------------------------------------------------------------------
/// Panics in the server's connection threads (`live-…`) since the hook went in: they mustn't
/// happen, but a thread's panic doesn't fail a test by itself.
static CONNECTION_PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn count_connection_panics() {
static HOOK: std::sync::Once = std::sync::Once::new();
HOOK.call_once(|| {
let before = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().name().is_some_and(|n| n.starts_with("live-")) {
CONNECTION_PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
before(info);
}));
});
}
#[test]
fn messages_are_capped_by_what_the_connection_is_for() {
count_connection_panics();
let dir = TempDir::new("caps");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
// Browsing a plugin directory takes small requests only; the oversized frame is refused and
// the connection closed cleanly (its reader isn't asked for the rest of that frame).
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: String::new() }));
raw.next();
raw.send_header(200_000);
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("too large") && reason.contains("KiB")));
drop(raw);
// A joined client whose batch is bigger than a file may be is told the limit.
let (_, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Small".into(), snapshot: snapshot("Small") }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
let mut joined = Raw::connect(addr);
joined.send(&Client::hello(&file.id, &edit.token, "Big", ""));
while !matches!(joined.next(), ServerMsg::Welcome { .. }) {}
joined.send_header(40 << 20);
let reason = loop {
if let ServerMsg::Refused { reason } = joined.next() {
break reason;
}
};
assert!(reason.contains("at most 17 MiB"), "{reason}");
drop(joined);
until(10, "the refused connections to close", || server.connections() == 0);
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0, "a connection thread panicked");
// Open to the public, a message is at most a file's size (and a margin), not 64 MiB.
assert_eq!(open(dir.path()).net().max_client_message, 17 << 20);
server.stop();
// Past the bytes the hub may have waiting, a connection is dropped as busy.
let cfg = open(dir.path());
let store = Store::open(dir.path(), cfg.store()).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut busy = Hosting::serve(listener, store, NetLimits { max_in_flight: 10_000, ..cfg.net() }).unwrap();
let mut raw = Raw::connect(busy.addr());
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
let mut big = Document::new("Big");
let page = big.first_page().unwrap();
big.create(page, NodeKind::Rectangle, &"z".repeat(30_000), 0.0, 0.0, 1.0, 1.0).unwrap();
raw.send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
busy.stop();
}
// M3 -------------------------------------------------------------------------------------------
#[test]
fn one_ipv6_block_gets_rising_but_bounded_limits() {
let dir = TempDir::new("blocks");
let cfg = Config { max_connections_per_ip: 1, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut held = Vec::new();
// Two /64s of one /56 fit (2 × the cap), the third doesn't.
for n in 1..=2 {
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{n}::1"));
assert!(matches!(r, ServerMsg::Hosting(_)), "/64 number {n}");
held.push(ws);
}
assert!(matches!(via_proxy(addr, "2001:db8:1:3::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")));
// Four of one /48 fit (4 × the cap) — two of them taken above — the fifth doesn't.
for n in 1..=2u16 {
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{:x}::1", n << 8));
assert!(matches!(r, ServerMsg::Hosting(_)), "/56 number {n}");
held.push(ws);
}
assert!(matches!(via_proxy(addr, "2001:db8:1:300::1").1, ServerMsg::Refused { .. }), "the /48 is full");
assert!(matches!(via_proxy(addr, "2001:db8:2::1").1, ServerMsg::Hosting(_)), "another /48 isn't");
drop(held);
server.stop();
}
#[test]
fn rates_count_per_block_and_publishing_for_everyone() {
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
// One connection a minute per /64, two per /56, four per /48.
let in_one_56 = (0..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, *n, 0, 0, 0, 0]), Action::Connect, t)).count();
assert_eq!(in_one_56, 2);
let in_one_48 = (1..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, n << 8, 0, 0, 0, 0]), Action::Connect, t)).count();
assert_eq!(in_one_48, 2, "the /48's four, two of them taken above");
// Publishing: one an hour per address, 32 for everyone together.
let published = (0..60u32).filter(|n| a.allow(IpAddr::from((0xC000_0200 + n).to_be_bytes()), Action::Publish, t)).count();
assert_eq!(published, 32);
}
#[test]
fn one_shot_requests_leave_connections_for_sessions() {
let dir = TempDir::new("reserve");
let cfg = Config { max_connections: 8, ..open(dir.path()) };
assert_eq!(cfg.net().reserve_for_sessions, 1);
let edit = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, edit, _) = store.publish("Team", &snapshot("Team")).unwrap();
(file.id, edit.token)
};
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut held = Vec::new();
for _ in 0..7 {
let mut raw = Raw::connect(addr);
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
held.push(raw);
}
let mut eighth = Raw::connect(addr);
eighth.send(&hosting());
assert!(matches!(eighth.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
drop(eighth);
until(5, "the refused connection to close", || server.connections() <= 7);
let mut session = Raw::connect(addr);
session.send(&Client::hello(&edit.0, &edit.1, "Ada", ""));
assert!(matches!(session.next(), ServerMsg::Welcome { .. }), "a session still gets in");
server.stop();
}
// M4 -------------------------------------------------------------------------------------------
#[test]
fn revoking_or_removing_cuts_library_connections_too() {
let dir = TempDir::new("libkick");
let cfg = config(dir.path());
let (file, view) = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 0)).unwrap();
(file.id, view)
};
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let invite = Invite { server: Endpoint::plain("x"), file: file.clone(), token: view.token.clone() };
let mut lib = Raw::connect(addr);
lib.send(&open_library(&invite));
lib.next();
let key = crate::test_util::key(dir.path());
manage::request(
&Endpoint::plain(addr.to_string()),
&key,
vec![Admin::Revoke { file: file.clone(), link: view.link.id.clone() }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("revoked")));
// A library connection with another link is cut when the file is removed.
let made = manage::request(
&Endpoint::plain(addr.to_string()),
&key,
vec![Admin::CreateLink { file: file.clone(), access: Access::View, label: String::new() }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
let Some(AdminReply::Link(l)) = made.into_iter().next() else { panic!() };
let mut lib = Raw::connect(addr);
lib.send(&open_library(&Invite { token: l.token, ..invite }));
lib.next();
manage::request(&Endpoint::plain(addr.to_string()), &key, vec![Admin::Remove { file }], &NetLimits::default(), Duration::from_secs(5)).unwrap();
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("removed")));
server.stop();
}
// L6, L7 ---------------------------------------------------------------------------------------
#[cfg(unix)]
#[test]
fn only_the_server_reads_its_data_directory_and_key() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("perms");
let data = dir.path().join("data");
let store = Store::open(&data, config(&data).store()).unwrap();
drop(store);
assert_eq!(std::fs::metadata(&data).unwrap().permissions().mode() & 0o777, 0o700);
assert_eq!(std::fs::metadata(data.join("server.key")).unwrap().permissions().mode() & 0o777, 0o600);
}
#[test]
fn downloads_have_a_share_per_address() {
let mut a = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
let me = ip("198.51.100.1");
let chunk = 16 << 20;
let served = (0..10).filter(|_| a.allow(me, Action::Download(chunk), t)).count();
assert_eq!(served, (DOWNLOAD_BURST as usize) / chunk, "a burst, then wait");
assert!(a.allow(ip("198.51.100.2"), Action::Download(chunk), t), "another address has its own share");
// A team library is served within it, then refused, saying why; another address still gets it.
let dir = TempDir::new("libshare");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 4 << 20)).unwrap();
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
let served = (0..40).take_while(|_| matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Library { .. })).count();
assert!((3..=5).contains(&served), "{served} downloads of 4 MiB in a burst of 16 MiB");
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Failed { reason } if reason == BUSY_DOWNLOADS));
assert!(matches!(fetch(&mut store, "198.51.100.9"), LibraryReply::Library { .. }));
}
// Second review: N2, L7, N3, M3, N5, N7 ------------------------------------------------------
#[test]
fn a_file_at_its_cap_is_compacted_at_most_once_a_minute() {
let dir = TempDir::new("forced");
let mut c = config(dir.path());
c.max_file_mb = Some(1);
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (info, ..) = store.publish("Full", &snapshot("Full")).unwrap();
let mut server = Backend::open(&mut store, &info.id).unwrap();
let mut client = join(&mut server);
let before = store.compactions();
// Alternate a small accepted batch with one that would take the file over its cap.
for i in 0..50 {
rename(&mut server, &mut client, &format!("page {i}"));
Backend::changed(&mut store, &info.id, &mut server);
assert!(Backend::room(&mut store, &info.id, &server, 2 << 20).is_err());
}
assert!(store.compactions() - before <= 1, "{} compactions for 50 over-cap batches", store.compactions() - before);
}
/// A plugin directory with one plugin whose bundle is about `mib` MiB.
fn plugin_store(dir: &std::path::Path, mib: usize) -> Store {
let mut store = Store::open(dir, config(dir).store()).unwrap();
let bundle = figcraft_plugin::bundle::Bundle {
manifest: crate::directory_tests::manifest("org.example.big", "1.0.0"),
wasm: crate::directory_tests::wasm(),
assets: vec![figcraft_plugin::bundle::Asset { name: "data.bin".into(), data: vec![9; mib << 20] }],
};
store.directory().publish(&bundle.encode()).unwrap();
store
}
#[test]
fn one_address_cant_spend_everyones_plugin_downloads() {
let dir = TempDir::new("plugshare");
let mut store = plugin_store(dir.path(), 4);
let fetch = || PluginReq::Fetch { id: "org.example.big".into(), version: None };
let greedy = ip("198.51.100.1");
// Its share runs out after a few; then 200 more tries cost nobody else anything.
let served =
(0..10).take_while(|_| matches!(store.plugins(greedy, false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. })).count();
assert!((2..=5).contains(&served), "{served}");
for _ in 0..200 {
let r = store.plugins(greedy, false, fetch());
assert!(matches!(&r, figcraft_collab::message::PluginReply::Failed { reason } if reason == BUSY_DOWNLOADS));
}
assert!(
matches!(store.plugins(ip("198.51.100.2"), false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. }),
"others still download"
);
}
#[test]
fn half_read_messages_count_against_a_reading_budget() {
let dir = TempDir::new("reading");
let cfg = open(dir.path());
let store = Store::open(dir.path(), cfg.store()).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut busy = Hosting::serve(listener, store, NetLimits { max_reading_block: 64 << 10, max_reading_total: 1 << 20, ..cfg.net() }).unwrap();
let mut raw = Raw::connect(busy.addr());
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)), "small messages are read");
let mut big = Document::new("Big");
let page = big.first_page().unwrap();
big.create(page, NodeKind::Rectangle, &"r".repeat(200_000), 0.0, 0.0, 1.0, 1.0).unwrap();
let _ = raw.try_send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy reading")));
until(10, "the reading budget to be given back", || busy.connections() == 0);
// Given back: another address reads again.
let mut raw = Raw::connect(busy.addr());
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
busy.stop();
}
#[test]
fn one_ipv6_block_takes_at_most_an_eighth_of_the_connections() {
let dir = TempDir::new("eighth");
let cfg = Config { max_connections: 64, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut held = Vec::new();
for n in 1..=8u16 {
let (ws, r) = via_proxy(addr, &format!("2001:db8:9:{:x}::1", n << 8));
assert!(matches!(r, ServerMsg::Hosting(_)), "connection {n}");
held.push(ws);
}
assert!(matches!(via_proxy(addr, "2001:db8:9:900::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")), "64 / 8");
assert!(matches!(via_proxy(addr, "2001:db8:a::1").1, ServerMsg::Hosting(_)), "another /48 still gets in");
drop(held);
server.stop();
}
#[test]
fn no_block_can_use_up_a_shared_budget() {
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
let t = Instant::now();
// A /48 publishes at most an eighth of what everyone may; everyone else keeps the rest.
let from_48 = (0..200u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 5, n << 8, 0, 0, 0, 0]), Action::Publish, t)).count();
assert_eq!(from_48, 40, "four addresses' worth");
let others = (0..400u32).filter(|n| a.allow(IpAddr::from((0xC633_6400 + n).to_be_bytes()), Action::Publish, t)).count();
assert_eq!(others, 320 - 40, "the rest of everyone's 320 an hour");
// Downloads: a /48's burst is at most an eighth of everyone's (checked when compiling, in `store`).
}
#[test]
fn problems_stay_bounded_and_a_full_disk_refuses_writes() {
let dir = TempDir::new("problems");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (info, ..) = store.publish("Broken", &snapshot("Broken")).unwrap();
std::fs::write(dir.path().join("files").join(&info.id).join("snapshot.bin"), b"junk").unwrap();
for _ in 0..500 {
assert!(Backend::open(&mut store, &info.id).is_err());
}
assert!(store.problems().len() <= 200, "{} problems kept", store.problems().len());
drop(store);
// Where the free space can be told, below the threshold nothing more is written.
if crate::disk::free_bytes(dir.path()).is_some() {
let mut c = config(dir.path()).store();
c.min_free_bytes = u64::MAX;
let mut store = Store::open(dir.path(), c).unwrap();
let e = store.publish("More", &snapshot("More")).unwrap_err();
assert!(e.contains("disk is nearly full"), "{e}");
}
}
// Third review: R1, N3, R2 -------------------------------------------------------------------
/// Read messages until `pick` takes one (others in between are skipped).
fn wait_for<T>(raw: &mut Raw, what: &str, mut pick: impl FnMut(ServerMsg) -> Option<T>) -> T {
loop {
match raw.next() {
ServerMsg::Refused { reason } => panic!("refused while waiting for {what}: {reason}"),
m => {
if let Some(t) = pick(m) {
return t;
}
}
}
}
}
#[test]
fn an_office_behind_one_address_works_together() {
let dir = TempDir::new("office");
let cfg = Config { max_file_mb: Some(4), max_plugin_mb: 1, ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
// A file near the size limit.
let mut d = Document::new("Office");
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, &"o".repeat(3_000_000), 0.0, 0.0, 10.0, 10.0).unwrap();
let (_, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Office".into(), snapshot: Replica::new(d).snapshot().unwrap() }],
&NetLimits::default(),
Duration::from_secs(10),
)
.unwrap();
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
// Twenty colleagues, one address, all joining at once: twenty welcomes of 3 MB.
let mut people: Vec<Raw> = (0..20).map(|_| Raw::connect(addr)).collect();
for (i, p) in people.iter_mut().enumerate() {
p.send(&Client::hello(&file.id, &edit.token, &format!("Person {i}"), ""));
}
let mut clients: Vec<Client> = people
.iter_mut()
.map(|p| Client::join(wait_for(p, "the welcome", |m| matches!(m, ServerMsg::Welcome { .. }).then_some(m))).unwrap())
.collect();
// Everyone moves their cursor (twenty people at ~30 a second share the address's budget).
for p in people.iter_mut() {
for k in 0..60 {
p.send(&ClientMsg::Presence(figcraft_collab::Presence { cursor: Some((page, f64::from(k), 0.0)), ..Default::default() }));
}
}
// One pastes 1 MB: the broadcast reaches all twenty.
let mut doc = clients[0].document().clone();
doc.create(page, NodeKind::Rectangle, &"p".repeat(1_000_000), 0.0, 0.0, 5.0, 5.0).unwrap();
let batch = clients[0].edit(&doc).unwrap().unwrap();
people[0].send(&batch);
for (i, p) in people.iter_mut().enumerate() {
let seq = wait_for(p, "the paste", |m| {
if let ServerMsg::Changes(a) = m {
a.changes.iter().any(|c| matches!(c, figcraft_collab::Change::Create { .. })).then_some(a.seq)
} else {
None
}
});
assert!(seq > 0, "person {i}");
}
// Still all connected.
for p in people.iter_mut() {
p.send(&ClientMsg::Ping);
wait_for(p, "the pong", |m| (m == ServerMsg::Pong).then_some(()));
}
assert_eq!(server.connections(), 20);
drop(clients.drain(..));
server.stop();
}
#[test]
fn the_biggest_reader_is_cut_off_when_everyones_reading_budget_runs_out() {
count_connection_panics();
let dir = TempDir::new("readers");
let cfg = open(dir.path());
let store = Store::open(dir.path(), cfg.store()).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let limits = NetLimits { max_reading_block: 10 << 20, max_reading_total: 300_000, ..cfg.net() };
let mut busy = Hosting::serve(listener, store, limits).unwrap();
let addr = busy.addr();
// A slow sender: it starts a 300 KB message, sends 250 KB of it and stops.
let mut hog = Raw::connect(addr);
hog.send(&hosting());
assert!(matches!(hog.next(), ServerMsg::Hosting(_)));
let mut frame = vec![0x82, 0x80 | 127];
frame.extend_from_slice(&300_000u64.to_be_bytes());
frame.extend_from_slice(&[1, 2, 3, 4]);
frame.extend(std::iter::repeat_n(0x55, 250_000));
hog.raw_write(&frame);
until(10, "the server to read the hog's bytes", || busy.reading_bytes() >= 250_000);
// Someone else sends a 100 KB message: the hog is cut off, not them.
let mut other = Raw::connect(addr);
other.send(&hosting());
assert!(matches!(other.next(), ServerMsg::Hosting(_)));
let mut d = Document::new("Other");
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, &"q".repeat(100_000), 0.0, 0.0, 1.0, 1.0).unwrap();
other.send(&ClientMsg::Admin(Admin::Publish { name: "Other".into(), snapshot: Replica::new(d).snapshot().unwrap() }));
assert!(matches!(other.next(), ServerMsg::Admin(_)), "the message was read and answered");
assert!(hog.closed_within(Duration::from_secs(10)), "the hog's connection was cut");
until(10, "the hog's bytes to be given back", || busy.reading_bytes() == 0);
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0);
busy.stop();
}
#[test]
fn the_server_forgets_addresses_whose_connections_are_gone() {
let dir = TempDir::new("forget");
let cfg = Config { trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
for n in 1..=10u8 {
let (ws, r) = via_proxy(addr, &format!("203.0.113.{n}"));
assert!(matches!(r, ServerMsg::Hosting(_)));
drop(ws);
}
until(10, "the connections to close", || server.connections() == 0);
let (_last, _) = via_proxy(addr, "203.0.113.200");
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
server.stop();
}
#[test]
fn a_library_publish_that_fails_keeps_serving_the_old_version() {
let dir = TempDir::new("libfail");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
// Version 1, then a changed library as version 2.
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1);
let files = dir.path().join(crate::store::FILES).join(&file.id);
// The description can't be written: a directory sits where its temporary file goes.
let tmp = crate::disk::temp_of(&files.join(crate::disk::META));
std::fs::create_dir(&tmp).unwrap();
assert!(store.publish_library(&file.id, &v2).is_err());
let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
let served = |reply: LibraryReply| match reply {
LibraryReply::Library { version, archive } => (version, archive),
other => panic!("{other:?}"),
};
assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes");
// A crash right after the archive was written leaves it on disk, the description unchanged.
std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap();
drop(store);
std::fs::remove_dir(&tmp).unwrap();
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart");
// Publishing again works, and serves the new version.
assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2);
assert_eq!(served(fetch(&mut store)), (2, v2));
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
}
+28
View File
@@ -8,8 +8,11 @@
//! that was confirmed and clients resume where they were. There are no user accounts yet: share
//! links are the access, and the server key manages files.
pub mod abuse;
pub mod cli;
pub mod codes;
pub mod config;
pub mod directory;
pub mod disk;
pub mod store;
@@ -37,6 +40,16 @@ impl Running {
self.hosting.connections()
}
/// Bytes waiting to be sent to clients, all connections together.
pub fn queued_bytes(&self) -> usize {
self.hosting.queued_bytes()
}
/// Blocks of addresses the server keeps counters for (see [`Hosting::blocks_remembered`]).
pub fn blocks_remembered(&self) -> usize {
self.hosting.blocks_remembered()
}
/// Stop (see the type's docs).
pub fn stop(&mut self) {
self.hosting.stop();
@@ -50,6 +63,9 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
for p in store.problems() {
log::warn!("{p}");
}
for w in cfg.warnings() {
log::warn!("{w}");
}
let files = store.len();
let addr = cfg.addr()?;
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
@@ -70,11 +86,23 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
Ok(Running { hosting })
}
#[cfg(test)]
mod background_tests;
#[cfg(test)]
mod cli_tests;
#[cfg(test)]
mod codes_tests;
#[cfg(test)]
mod directory_tests;
#[cfg(test)]
mod hardening_tests;
#[cfg(test)]
mod library_tests;
#[cfg(test)]
mod owned_tests;
#[cfg(test)]
mod public_tests;
#[cfg(test)]
mod server_tests;
#[cfg(test)]
mod store_tests;
+2 -2
View File
@@ -130,7 +130,7 @@ fn publish_enable_update_and_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(), 2_000, "reports are 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 { .. }));
@@ -141,7 +141,7 @@ fn publish_enable_update_and_usage() {
drop(server);
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
assert!(matches!(
store.library(&edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
store.library(std::net::Ipv4Addr::LOCALHOST.into(), &edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
LibraryReply::Library { version: 2, .. }
));
}
+194
View File
@@ -0,0 +1,194 @@
//! Your online files against a real server: a file shared online is recorded with its owner
//! token; after a restart of the app the server says what became of it (`Admin::Owned`, one
//! request for all the files), it opens again as its owner in step with everyone who joined by
//! code, its sharing stops and starts from the record, and removing it takes it off the server's
//! disk. The request itself: only a file's own token learns about it, junk never hurts, and an
//! owner's question opens nothing (a file isn't kept alive by being asked about).
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, MAX_OWNED, OpenHosting, PROTOCOL, ServerMsg};
use figcraft_engine::Prefs;
use serde_json::{Value, json};
use crate::config::Config;
use crate::test_util::{Raw, TempDir, config, live, pump, same, session};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, ..config(dir) }
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// An open hosting connection that names no file.
fn hosting(addr: std::net::SocketAddr) -> Raw {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw
}
fn owned(raw: &mut Raw, files: Vec<(String, String)>) -> AdminReply {
raw.send(&ClientMsg::Admin(Admin::Owned { files }));
match raw.next() {
ServerMsg::Admin(r) => *r,
other => panic!("answered {other:?}"),
}
}
/// Ada shares her open file online through the server at `url`: the app with the file live.
fn share(url: &str) -> figcraft_engine::Session {
let mut a = session("Ada");
ex(&mut a, "pref.publishServer", json!({"url": url}));
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
ex(&mut a, "collab.share", json!({}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some()));
a
}
/// The app quits (its sessions end with it); the next run has only the preferences.
fn restart(a: figcraft_engine::Session, name: &str) -> figcraft_engine::Session {
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&a.prefs).unwrap()).unwrap();
drop(a);
let mut next = session(name);
next.prefs = prefs;
next
}
#[test]
fn a_shared_file_is_its_owners_again_after_the_app_restarts() {
let dir = TempDir::new("owned-restart");
let mut server = crate::start(&open(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let a = share(&url);
let code = a.live_summary()["code"].as_str().unwrap().to_string();
let file = a.prefs.online_files[0].file.clone();
let owner = a.prefs.online_files[0].owner.clone();
assert_eq!(a.live().published.as_ref().and_then(|p| p.owner.clone()), Some(owner.clone()));
// The app quits. The next run knows the file from its preferences alone.
let mut a = restart(a, "Ada");
let list = ex(&mut a, "collab.files", json!({}));
assert_eq!((&list["files"][0]["file"], &list["files"][0]["state"], &list["files"][0]["code"]), (&json!(file), &json!("unknown"), &json!(code)));
// The server says it is there, and still shared.
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"], &list["problems"]), (&json!("online"), &json!(true), &json!([])));
assert!(list["files"][0]["expires"].as_u64().is_some_and(|e| e > crate::store::now()));
assert!(!list.to_string().contains(&owner));
// Open it: the owner is in the file, synced, in a tab of its own.
let opened = ex(&mut a, "collab.openFile", json!({"file": file}));
assert!(!opened.to_string().contains(&owner));
pump(&mut [&mut a], "the owner back in", |s| live(s[0]));
assert_eq!(a.docs.len(), 2);
assert!(!a.live_summary().to_string().contains(&owner), "the token stays in the record");
// Someone joins with the code given out before the restart, and sees the owner's edits.
let mut b = session("Bo");
ex(&mut b, "pref.publishServer", json!({"url": url}));
ex(&mut b, "collab.join", json!({"code": code}));
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
ex(&mut a, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
pump(&mut [&mut a, &mut b], "the edit at Bo's", |s| same(s[0].doc().unwrap(), s[1].doc().unwrap()));
// Stop sharing from the list (no tab needed): Bo is out, the owner stays, the code is dead.
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false);
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active() && live(s[0]));
assert_eq!(a.live_summary()["share"]["shared"], false, "the open tab follows");
let mut c = session("Cy");
ex(&mut c, "pref.publishServer", json!({"url": url}));
ex(&mut c, "collab.join", json!({"code": code}));
pump(&mut [&mut c], "the old code refused", |s| matches!(s[0].live().status, figcraft_engine::live::Status::Failed { .. }));
// Start again: a new code for the same file.
let again = ex(&mut a, "collab.share", json!({}));
let new_code = again["code"].as_str().unwrap().to_string();
assert_ne!(new_code, code);
assert_eq!(ex(&mut a, "collab.files", json!({}))["files"][0]["code"], new_code);
let mut d = session("Di");
ex(&mut d, "pref.publishServer", json!({"url": url}));
ex(&mut d, "collab.join", json!({"code": new_code}));
pump(&mut [&mut a, &mut d], "Di in with the new code", |s| live(s[1]));
// Rename, then remove: the file leaves the server's disk and the list; the tab's session ends.
ex(&mut a, "collab.manageFile", json!({"file": file, "action": "rename", "name": "Landing"}));
assert_eq!(ex(&mut a, "collab.files", json!({"refresh": true}))["files"][0]["name"], "Landing");
assert!(dir.path().join("files").join(&file).exists());
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "remove"}))["done"], true);
assert!(!dir.path().join("files").join(&file).exists(), "gone from the data directory");
assert!(a.prefs.online_files.is_empty() && !a.live().status.active() && a.docs.len() == 2, "the copy stays open");
server.stop();
}
#[test]
fn a_file_that_expired_or_was_removed_elsewhere_shows_as_gone() {
let dir = TempDir::new("owned-gone");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = share(&url);
let file = a.prefs.online_files[0].file.clone();
ex(&mut a, "collab.leave", json!({}));
let mut a = restart(a, "Ada");
// The operator removes it.
crate::cli::run(&crate::cli::Cli { config: cfg.clone(), command: crate::cli::Command::RemoveFile { file: file.clone() } }).unwrap();
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"]), (&json!("gone"), &json!(false)));
assert!(a.execute("collab.openFile", &json!({"file": file})).unwrap_err().to_string().contains("no longer on its server"));
assert_eq!(ex(&mut a, "collab.forgetFile", json!({"file": file}))["onServer"], false);
// The server stops: the list says it couldn't be reached and keeps what it has.
let mut b = share(&url);
ex(&mut b, "collab.leave", json!({}));
server.stop();
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
assert_eq!((&list["files"][0]["state"], &list["problems"][0]["state"]), (&json!("online"), &json!("offline")));
assert_eq!(list["files"].as_array().unwrap().len(), 1);
}
#[test]
fn only_a_files_own_token_learns_about_it() {
let dir = TempDir::new("owned-ask");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let a = share(&format!("ws://{addr}/"));
let (file, owner) = (a.prefs.online_files[0].file.clone(), a.prefs.online_files[0].owner.clone());
let opened_before = std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap();
let mut raw = hosting(addr);
let pair = |f: &str, o: &str| (f.to_string(), o.to_string());
let reply = owned(
&mut raw,
vec![pair(&file, &owner), pair(&file, "WrongTokenWrongToken00"), pair("nosuchfile", &owner), pair("", ""), pair("../../etc", "x")],
);
let AdminReply::Owned(list) = reply else { panic!("answered {reply:?}") };
assert_eq!(list.len(), 5, "one answer per pair, in order");
let first = list[0].as_ref().expect("its own token");
assert_eq!(first.name, "Untitled");
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
assert!(first.expires.is_some());
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
assert_eq!(owned(&mut raw, vec![]), AdminReply::Owned(vec![]));
let many = vec![pair(&file, &owner); MAX_OWNED + 1];
assert!(matches!(owned(&mut raw, many), AdminReply::Failed { reason } if reason.contains("at most")));
assert!(matches!(owned(&mut raw, vec![pair(&file, &owner); MAX_OWNED]), AdminReply::Owned(l) if l.len() == MAX_OWNED));
// Junk ids and tokens (long, control characters, other scripts) are just not theirs.
let junk = vec![pair(&"x".repeat(100_000), &"y".repeat(100_000)), pair("\u{0}\u{7}", "\u{1F4A5}"), pair(&file, &format!("{owner}\u{0}"))];
assert!(matches!(owned(&mut raw, junk), AdminReply::Owned(l) if l.iter().all(Option::is_none)));
// On that connection the token still manages nothing: it named no file when it opened.
raw.send(&ClientMsg::Admin(Admin::Remove { file: file.clone() }));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { .. })));
assert!(dir.path().join("files").join(&file).exists());
// Asking opened nothing: when the file was last opened is as it was.
assert_eq!(std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap(), opened_before);
// A connection that manages one file with its token may ask too; the server key as well.
let mut one = Raw::connect(addr);
one.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: file.clone(), owner: owner.clone() }));
assert!(matches!(one.next(), ServerMsg::Hosting(_)));
assert!(matches!(owned(&mut one, vec![pair(&file, &owner)]), AdminReply::Owned(l) if l[0].is_some()));
drop(a);
server.stop();
}
#[test]
fn a_server_that_isnt_open_to_the_public_answers_that_nothing_is_owned() {
let dir = TempDir::new("owned-private");
let mut server = crate::start(&config(dir.path())).unwrap();
let mut raw = hosting(server.addr());
assert!(matches!(owned(&mut raw, vec![("f".into(), "t".into())]), AdminReply::Owned(l) if l == [None]));
server.stop();
}
+495
View File
@@ -0,0 +1,495 @@
//! A server open to the public: anyone publishes without the key and manages the file with its
//! owner token; the caps per address and for everyone, rate limits, idle expiry, blocking,
//! reports, the uptime check, `X-Forwarded-For` only from the trusted proxy, and old clients.
use std::io::{Read, Write};
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ServerMsg};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Document, NodeKind};
use serde_json::{Value, json};
use tungstenite::client::IntoClientRequest;
use tungstenite::{Bytes, Message};
use crate::abuse::{Abuse, AbuseConfig, Action, MAX_ADDRESSES};
use crate::cli::{self, Cli, Command, Sort};
use crate::config::Config;
use crate::store::{KEY_ONLY, Store};
use crate::test_util::{Raw, TempDir, config, join, key, live, pump, session};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "[email protected]".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn cli(cfg: &Config, command: Command) -> Result<String, String> {
cli::run(&Cli { config: cfg.clone(), command })
}
#[test]
fn anyone_publishes_and_the_owner_token_manages_the_file() {
let dir = TempDir::new("public");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let url = format!("ws://{addr}/");
let mut a = session("Ada");
// The server says it is open, who runs it and its terms before anything is uploaded.
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
assert_eq!(info["open"], true);
assert_eq!(info["operator"], "[email protected]");
assert_eq!(info["terms"], "https://example.com/terms");
assert_eq!(info["idleDays"], 30.0);
// Published without a key: links, an owner token shown once, and an expiry date.
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live().hosting.is_some());
let p = a.live().published.clone().unwrap();
let owner = p.owner.clone().expect("an owner token");
assert_eq!(owner.len(), 22, "128 bits");
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
let summary = a.live_summary();
assert_eq!(summary["published"]["owner"], owner.as_str());
assert_eq!(summary["hosting"]["operator"], "[email protected]");
// The owner token is kept only as a hash.
let meta = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("meta.json")).unwrap();
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
// Someone joining hears about the hosting too (with the file's expiry).
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live().hosting.as_ref().is_some_and(|h| h.expires.is_some()));
// The owner lists, makes, revokes links and renames the file.
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
p2.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("collab.manageFile", &p2)
};
let links = manage(&mut a, json!({"action": "links"})).unwrap();
assert_eq!(links["links"].as_array().unwrap().len(), 2);
let made = manage(&mut a, json!({"action": "createLink", "access": "view", "label": "Review"})).unwrap();
assert!(made["link"].as_str().unwrap().starts_with("figcraft://join?host="), "{made}");
let view_id = links["links"].as_array().unwrap().iter().find(|l| l["access"] == "view").unwrap()["id"].as_str().unwrap().to_string();
assert_eq!(manage(&mut a, json!({"action": "revoke", "link": view_id})).unwrap()["done"], true);
manage(&mut a, json!({"action": "rename", "name": "Poster v2"})).unwrap();
assert!(cli(&cfg, Command::Files { sort: Sort::Size }).unwrap().contains("Poster v2"));
// A wrong token, or another file's, manages nothing.
let e = a.execute("collab.manageFile", &json!({"server": url, "file": p.file, "owner": "AAAAAAAAAAAAAAAAAAAAAA", "action": "links"}));
assert!(e.unwrap_err().to_string().contains("doesn't manage"));
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner: owner.clone() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw.send(&ClientMsg::Admin(Admin::Remove { file: "0000000000000000".into() }));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
raw.send(&ClientMsg::Admin(Admin::Listing));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
// Removing it disconnects everyone in it (whoever removed it just leaves: no failure there).
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
pump(&mut [&mut a, &mut b], "everyone told", |s| s[0].live_summary()["status"] == "off" && s[1].live_summary()["status"] == "failed");
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
assert!(!dir.path().join("files").join(&p.file).exists());
server.stop();
}
#[test]
fn a_private_server_still_needs_its_key() {
let dir = TempDir::new("private");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
assert_eq!(info["open"], false);
ex(&mut a, "collab.publish", json!({"server": url}));
pump(&mut [&mut a], "the refusal", |s| s[0].live_summary()["status"] == "failed");
assert!(a.message.as_deref().unwrap_or_default().contains("only from people who have its key"), "{:?}", a.message);
assert!(!dir.path().join("files").read_dir().unwrap().any(|_| true), "nothing kept");
// The operator's key publishes as before (no owner token, no expiry).
let p = crate::test_util::publish(&mut a, dir.path(), server.addr());
assert!(p.owner.is_none() && p.expires.is_none());
server.stop();
}
#[test]
fn caps_per_address_and_for_everyone() {
let dir = TempDir::new("caps");
let mut c = open(dir.path());
c.max_files_per_ip = 2;
c.publishes_per_hour = 100;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (a, b) = (ip("203.0.113.1"), ip("203.0.113.2"));
store.publish_open(a, "One", &snapshot("One")).unwrap();
store.publish_open(a, "Two", &snapshot("Two")).unwrap();
let e = store.publish_open(a, "Three", &snapshot("Three")).unwrap_err();
assert!(e.contains("keeps 2 files"), "{e}");
store.publish_open(b, "Other", &snapshot("Other")).unwrap();
drop(store);
// Bytes per address, and for everyone (a byte: nothing fits).
let mut tight = c.store();
tight.hosting.max_bytes_per_ip = 1;
let mut store = Store::open(dir.path(), tight.clone()).unwrap();
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
assert!(e.contains("MiB it may keep"), "{e}");
drop(store);
tight.hosting.max_bytes_per_ip = u64::MAX;
tight.hosting.max_total_bytes = 1;
let mut store = Store::open(dir.path(), tight).unwrap();
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
assert!(e.contains("server is full"), "{e}");
drop(store);
// Publishing too often.
let mut c3 = open(dir.path());
c3.publishes_per_hour = 1;
let mut store = Store::open(dir.path(), c3.store()).unwrap();
store.publish_open(ip("198.51.100.7"), "Once", &snapshot("Once")).unwrap();
let e = store.publish_open(ip("198.51.100.7"), "Twice", &snapshot("Twice")).unwrap_err();
assert!(e.contains("too many files published"), "{e}");
// Closed to the public: nobody publishes without the key.
drop(store);
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
}
#[test]
fn idle_files_published_without_the_key_expire() {
let dir = TempDir::new("expiry");
let mut c = open(dir.path());
c.idle_days = 2.0;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
let (fresh, ..) = store.publish_open(ip("203.0.113.9"), "Fresh", &snapshot("Fresh")).unwrap();
let (keyed, ..) = store.publish("Operator's", &snapshot("Operator's")).unwrap();
store.age(&public.id, 2 * 86_400 + 1);
store.age(&keyed.id, 365 * 86_400);
store.age(&fresh.id, 86_400);
assert_eq!(store.sweep(), vec![public.id.clone()]);
let ids: Vec<String> = store.listing(&|_| None).into_iter().map(|r| r.info.id).collect();
assert!(!ids.contains(&public.id) && ids.contains(&fresh.id) && ids.contains(&keyed.id));
assert!(!dir.path().join("files").join(&public.id).exists());
// Opening it counts as using it: idle time starts over.
let mut server = figcraft_collab_net::hub::Backend::open(&mut store, &fresh.id).unwrap();
store.age(&fresh.id, 0);
assert!(store.listing(&|_| None).iter().any(|r| r.info.id == fresh.id && r.expires.unwrap() > crate::store::now() + 86_400));
// An open file never expires under someone.
store.age(&fresh.id, 10 * 86_400);
assert!(store.sweep().is_empty());
figcraft_collab_net::hub::Backend::idle(&mut store, &fresh.id, &mut server);
assert!(store.sweep().is_empty(), "everyone just left: the idle time starts now");
// A startup finds what expired while the server was down.
store.age(&fresh.id, 3 * 86_400);
drop(store);
let store = Store::open(dir.path(), c.store()).unwrap();
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
}
#[test]
fn rate_limits_and_blocks_refuse_with_a_reason() {
let dir = TempDir::new("limits");
let mut c = open(dir.path());
c.connects_per_minute = 3;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let ask = || {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting {
protocol: PROTOCOL,
client_version: String::new(),
file: String::new(),
owner: String::new(),
}));
raw.next()
};
for _ in 0..3 {
assert!(matches!(ask(), ServerMsg::Hosting(_)));
}
assert!(matches!(ask(), ServerMsg::Refused { reason } if reason.contains("too many connections from your address")));
server.stop();
// Messages per address, and blocking.
let mut c = open(dir.path());
c.messages_per_second = 5;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
let refused = (0..200).find_map(|_| {
raw.send(&ClientMsg::Ping);
match raw.next() {
ServerMsg::Refused { reason } => Some(reason),
_ => None,
}
});
assert!(refused.is_some_and(|r| r.contains("too many messages from your address")));
let mut held = Raw::connect(addr);
held.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(held.next(), ServerMsg::Hosting(_)));
assert!(cli(&c, Command::Block { ip: "127.0.0.1".into() }).unwrap().contains("Blocked"));
assert!(matches!(held.next(), ServerMsg::Refused { reason } if reason.contains("no longer accepts")));
let mut again = Raw::connect(addr);
again.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(again.next(), ServerMsg::Refused { .. }));
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
server.stop();
// Kept across a restart (the commands work on the directory itself then).
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
assert!(cli(&c, Command::Unblock { ip: "127.0.0.1".into() }).unwrap().contains("Unblocked"));
assert!(cli(&c, Command::Block { ip: "2001:db8:1:2:3:4:5:6".into() }).unwrap().contains("Blocked"));
assert_eq!(cli(&c, Command::Blocked).unwrap(), "2001:db8:1:2::\n", "an IPv6 address blocks its /64");
assert!(cli(&c, Command::Block { ip: "not an address".into() }).is_err());
}
#[test]
fn participants_report_files_to_the_operator() {
let dir = TempDir::new("reports");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
let mut b = join(&p.view, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live().hosting.is_some());
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));
let reports = cli(&cfg, Command::Reports).unwrap();
assert!(reports.contains(&p.file) && reports.contains("Spam with a link") && reports.contains("Flyer"), "{reports}");
assert!(cli(&cfg, Command::Files { sort: Sort::Idle }).unwrap().lines().any(|l| l.contains(&p.file) && l.contains("127.0.0.1")));
server.stop();
// Kept on disk.
assert!(cli(&cfg, Command::Reports).unwrap().contains("Spam with a link"));
}
/// Connect with `X-Forwarded-For: forwarded` and open a hosting connection: what the server said.
pub(crate) fn via_proxy(addr: SocketAddr, forwarded: &str) -> (tungstenite::WebSocket<TcpStream>, ServerMsg) {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut req = format!("ws://{addr}/").into_client_request().unwrap();
req.headers_mut().insert("X-Forwarded-For", forwarded.parse().unwrap());
let (mut ws, _) = tungstenite::client::client(req, s).unwrap();
let open = ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() });
ws.send(Message::Binary(Bytes::from(message::encode(&open).unwrap()))).unwrap();
let reply = loop {
if let Message::Binary(b) = ws.read().unwrap() {
break message::decode(&b, usize::MAX).unwrap();
}
};
(ws, reply)
}
#[test]
fn forwarded_addresses_count_only_from_the_trusted_proxy() {
let dir = TempDir::new("proxy");
let mut c = open(dir.path());
c.max_connections_per_ip = 1;
c.trusted_proxy = Some("127.0.0.1".into());
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
// Behind the proxy each client is its own address: the last one the proxy added.
let (_one, r) = via_proxy(addr, "203.0.113.1");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_two, r) = via_proxy(addr, "10.0.0.1, 203.0.113.2");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_three, r) = via_proxy(addr, "203.0.113.2, 203.0.113.1");
assert!(matches!(r, ServerMsg::Refused { reason } if reason.contains("too many connections")));
// Blocking the forwarded address blocks that client only.
cli(&c, Command::Block { ip: "203.0.113.9".into() }).unwrap();
assert!(matches!(via_proxy(addr, "203.0.113.9").1, ServerMsg::Refused { .. }));
assert!(matches!(via_proxy(addr, "203.0.113.10").1, ServerMsg::Hosting(_)));
server.stop();
// Without a trusted proxy the header is anyone's to forge: the connection's own address counts.
c.trusted_proxy = None;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let (_one, r) = via_proxy(addr, "203.0.113.1");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_two, r) = via_proxy(addr, "203.0.113.2");
assert!(matches!(r, ServerMsg::Refused { .. }));
server.stop();
}
/// Send `request` on a raw socket; what came back.
fn http(addr: SocketAddr, request: &str) -> String {
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
s.write_all(request.as_bytes()).unwrap();
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
}
#[test]
fn uptime_checks_share_the_port() {
let dir = TempDir::new("healthz");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let ok = http(addr, "GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n");
assert!(ok.starts_with("HTTP/1.1 200 OK") && ok.ends_with("ok\n"), "{ok}");
// Under a path (behind a proxy's prefix), and with a query.
assert!(http(addr, "GET /figcraft/healthz?x=1 HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 200"));
// Anything else is a WebSocket handshake or nothing.
assert!(!http(addr, "GET /healthzz HTTP/1.1\r\n\r\n").contains("200 OK"));
assert!(!http(addr, "POST /healthz HTTP/1.1\r\n\r\n").contains("200 OK"));
// Sessions still work beside it.
let mut a = session("Ada");
let p = crate::test_util::publish(&mut a, dir.path(), addr);
assert!(!p.edit.is_empty());
server.stop();
}
#[test]
fn older_clients_get_clear_answers() {
let dir = TempDir::new("old");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
// An older app tries to publish with a wrong key: it is told the server is open.
let e =
manage::request(&Endpoint::plain(addr.to_string()), "wrong", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("open to the public"), "{e}");
// A hello that doesn't announce `hosting` is never sent what it can't read: after its
// welcome, the answer to its ping comes before anything about hosting would.
let mut a = session("Ada");
let p = crate::test_util::publish(&mut a, dir.path(), addr);
let edit = Invite::parse(&p.edit).unwrap();
let mut raw = Raw::connect(addr);
let mut hello = figcraft_collab::Client::hello(&edit.file, &edit.token, "Old", "");
if let ClientMsg::Hello(h) = &mut hello {
h.client_version = "0.3.0 signals".into();
}
raw.send(&hello);
raw.send(&ClientMsg::Ping);
let seen: Vec<ServerMsg> = std::iter::from_fn(|| Some(raw.next())).take_while(|m| *m != ServerMsg::Pong).collect();
assert!(seen.iter().any(|m| matches!(m, ServerMsg::Welcome { .. })));
assert!(!seen.iter().any(|m| matches!(m, ServerMsg::Hosting(_))), "{seen:?}");
// The operator's key still manages everything.
let ok =
manage::request(&Endpoint::plain(addr.to_string()), &key(dir.path()), vec![Admin::Listing], &NetLimits::default(), Duration::from_secs(5))
.unwrap();
assert!(matches!(&ok[..], [AdminReply::Listing(rows)] if rows.len() == 1));
server.stop();
}
#[test]
fn the_address_table_stays_bounded() {
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
for i in 0..(MAX_ADDRESSES as u32 * 3) {
assert!(abuse.allow(IpAddr::from(i.to_be_bytes()), Action::Connect, t + Duration::from_micros(u64::from(i))));
}
assert!(abuse.len() <= MAX_ADDRESSES);
// A remembered address is limited; a private server limits only reports.
let me = ip("192.0.2.1");
assert!(abuse.allow(me, Action::Connect, t) && !abuse.allow(me, Action::Connect, t));
let mut closed = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
assert!((0..100).all(|_| closed.allow(me, Action::Connect, t)));
assert!((0..10).all(|_| closed.allow(me, Action::Report, t)) && !closed.allow(me, Action::Report, t));
}
#[test]
fn open_mode_has_safe_defaults_and_warns_the_operator() {
let private = Config::default();
assert_eq!(private.file_mb(), 64, "a private server behaves as before");
assert!(private.warnings().is_empty());
assert_eq!(private.net().max_per_address, 0);
let open = Config { open_publish: true, public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
assert_eq!(open.file_mb(), 16, "strangers' files stay small");
assert_eq!(open.store().hosting.idle_secs, 30 * 86_400, "idle files expire");
assert_eq!(open.net().max_per_address, 32);
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
assert_eq!(open.net().max_client_message, 17 << 20);
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
let w = open.warnings().join("\n");
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
let set = Config { operator_contact: "[email protected]".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
assert!(set.warnings().is_empty(), "{:?}", set.warnings());
assert_eq!(set.file_mb(), 8);
assert!(Config { trusted_proxy: Some("localhost".into()), ..set.clone() }.check().is_err());
assert!(Config { terms_url: "javascript:alert(1)".into(), ..set.clone() }.check().is_err());
assert!(Config { idle_days: f64::NAN, ..set }.check().is_err());
}
#[test]
fn open_connections_never_reach_what_needs_the_key() {
let dir = TempDir::new("keyonly");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
let mut store_side = session("Ada");
let keyed = crate::test_util::publish(&mut store_side, dir.path(), addr);
let other = Invite::parse(&keyed.edit).unwrap().file;
// A file of one's own, with its owner token.
let (info, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Mine".into(), snapshot: snapshot("Mine") }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(info.open);
let Some(AdminReply::Hosted { file: mine, owner, .. }) = replies.into_iter().next() else { panic!("not hosted") };
let key_only = vec![
Admin::Files,
Admin::Listing,
Admin::Reports,
Admin::Blocked,
Admin::Block { ip: "203.0.113.1".into() },
Admin::Unblock { ip: "203.0.113.1".into() },
Admin::Links { file: other.clone() },
Admin::CreateLink { file: other.clone(), access: figcraft_collab::Access::Edit, label: String::new() },
Admin::Revoke { file: other.clone(), link: "x".into() },
Admin::Rename { file: other.clone(), name: "Taken".into() },
Admin::Remove { file: other.clone() },
];
// Without a token, and with the token of another file: every one is refused.
for (file, token) in [("", ""), (mine.id.as_str(), owner.as_str())] {
let (_, replies) =
manage::hosting(&Endpoint::plain(addr.to_string()), file, token, key_only.clone(), &NetLimits::default(), Duration::from_secs(5))
.unwrap();
for (req, r) in key_only.iter().zip(replies) {
assert!(matches!(r, AdminReply::Failed { .. }), "{req:?} answered {r:?}");
}
}
// The keyed file is untouched (its name, both links), nothing is blocked, and the plugin
// directory won't take a bundle.
let k = key(dir.path());
let check = manage::request(
&Endpoint::plain(addr.to_string()),
&k,
vec![Admin::Listing, Admin::Links { file: other.clone() }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(matches!(&check[0], AdminReply::Listing(rows) if rows.iter().any(|r| r.info.id == other && r.info.name == "Team file")));
assert!(matches!(&check[1], AdminReply::Links(links) if links.len() == 2));
assert!(std::fs::read_to_string(dir.path().join("blocked.json")).is_err());
let e = manage::plugins(
&Endpoint::plain(addr.to_string()),
"",
figcraft_collab::message::PluginReq::Publish { bundle: vec![1, 2, 3] },
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(matches!(e, figcraft_collab::message::PluginReply::Failed { .. }), "{e:?}");
// Open-mode answers never carry the server key.
assert!(!format!("{info:?}").contains(&k));
server.stop();
}
+46 -17
View File
@@ -34,8 +34,8 @@ fn publish_then_edit_and_view_together() {
// Nothing changes it until it is on the server.
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
assert!(e.contains("being published"), "{e}");
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
assert_eq!(a.live_summary()["readOnly"], false);
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
@@ -43,7 +43,7 @@ fn publish_then_edit_and_view_together() {
// Bo joins with the edit link, Cy with the view link.
let mut b = join(&p.edit, "Bo");
let mut c = join(&p.view, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live().peers.len() == 2);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(c.live_summary()["access"], "view");
assert_eq!(c.live_summary()["readOnly"], true);
@@ -64,9 +64,9 @@ fn publish_then_edit_and_view_together() {
assert!(c.begin_interaction("Move").is_err());
assert!(same(&before, c.doc().unwrap()));
// Cy is still seen: presence goes both ways.
let cy = c.live.info.as_ref().unwrap().me;
let cy = c.live().info.as_ref().unwrap().me;
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live().presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
// A client that skips the app's check is refused by the server.
let view = Invite::parse(&p.view).unwrap();
let mut raw = Raw::connect(addr);
@@ -90,7 +90,7 @@ fn publish_then_edit_and_view_together() {
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
// Leaving keeps a local copy.
c.execute("collab.leave", &json!({})).unwrap();
assert_eq!(c.live.status, Status::Off);
assert_eq!(c.live().status, Status::Off);
assert!(c.doc().unwrap().contains(rect));
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
@@ -106,21 +106,21 @@ fn a_restart_mid_session_loses_no_confirmed_edit() {
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
let (ada, bo) = (a.live().info.as_ref().unwrap().me, b.live().info.as_ref().unwrap().me);
// The server stops: both reconnect, and what Ada makes meanwhile waits.
server.stop();
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live().status, Status::Reconnecting { .. })));
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
cfg.listen = addr.to_string();
let mut server = start_again(&cfg);
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
});
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
assert_eq!(a.live().info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live().info.as_ref().unwrap().me, bo, "so did Bo");
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
// Someone new gets it all from the restarted server.
let mut c = join(&p.view, "Cy");
@@ -160,20 +160,20 @@ fn revoked_links_stop_working_at_once() {
assert_eq!((files.len(), files[0].people), (1, 3));
// Revoked: Cy is out at once, the others stay.
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
});
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
assert!(live(&b));
// The link no longer joins.
c.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live().status, Status::Failed { reason } if reason.contains("isn't valid")));
// Managing needs the key.
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("wrong server key"), "{e}");
// Removing the file ends it for everyone in it.
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b], "everyone out", |s| {
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
s.iter().all(|x| matches!(&x.live().status, Status::Failed { reason } if reason.contains("removed")))
});
server.stop();
}
@@ -181,7 +181,10 @@ fn revoked_links_stop_working_at_once() {
#[test]
fn fuzzed_frames_dont_hurt_the_server() {
let dir = TempDir::new("fuzz");
let mut server = crate::start(&config(dir.path())).unwrap();
// Open to the public, so publishing without the key and owner tokens are fuzzed too (with
// rates high enough that the noise gets in).
let cfg = crate::config::Config { open_publish: true, connects_per_minute: 100_000, messages_per_second: 100_000, ..config(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
@@ -200,6 +203,32 @@ fn fuzzed_frames_dont_hurt_the_server() {
have: None,
usage: Some(message::Usage { file: "Fuzz".into(), components: vec![(NodeId(1), 2)] }),
}),
ClientMsg::OpenPlugins(message::OpenPlugins { protocol: message::PROTOCOL, key: String::new(), client_version: String::new() }),
ClientMsg::Plugins(message::PluginReq::List { query: "tiny".into() }),
ClientMsg::OpenHosting(message::OpenHosting {
protocol: message::PROTOCOL,
client_version: String::new(),
file: String::new(),
owner: String::new(),
}),
ClientMsg::OpenHosting(message::OpenHosting {
protocol: message::PROTOCOL,
client_version: String::new(),
file: edit.file.clone(),
owner: "AAAAAAAAAAAAAAAAAAAAAA".into(),
}),
ClientMsg::Admin(Admin::Publish {
name: "Fuzz".into(),
snapshot: figcraft_collab::Replica::new(figcraft_doc::Document::new("F")).snapshot().unwrap(),
}),
ClientMsg::Admin(Admin::Rename { file: edit.file.clone(), name: "Fuzzed".into() }),
ClientMsg::Admin(Admin::Listing),
ClientMsg::Admin(Admin::Block { ip: "203.0.113.1".into() }),
ClientMsg::Report { reason: "fuzz".into() },
ClientMsg::Plugins(message::PluginReq::Fetch { id: "org.example.tiny".into(), version: None }),
ClientMsg::Plugins(message::PluginReq::Publish {
bundle: figcraft_plugin::bundle::Bundle { manifest: "{}".into(), wasm: b"\0asm".to_vec(), assets: Vec::new() }.encode(),
}),
ClientMsg::Ping,
]
.iter()
File diff suppressed because it is too large. Load diff
+46 -2
View File
@@ -99,7 +99,7 @@ fn a_file_survives_a_crash_and_compacts() {
let dir = TempDir::new("crash");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, before, seq) = {
let mut store = Store::open(dir.path(), c).unwrap();
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Crash");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
@@ -131,7 +131,7 @@ fn a_file_survives_a_crash_and_compacts() {
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c).unwrap();
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
@@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() {
let _ = disk::read_log(&bytes);
}
}
/// The first page's name in `server`'s document.
fn page_name(server: &Server) -> String {
let d = server.replica().document();
d.node(d.first_page().unwrap()).unwrap().name.clone()
}
#[test]
fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let dir = TempDir::new("cutshort");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let folder = dir.path().join(FILES);
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
// The next compaction writes its snapshot, then can't start its log.
let blocker = disk::temp_of(&folder.join(&id).join(LOG));
fs::create_dir(&blocker).unwrap();
// What the hub would confirm: every change whose flush didn't report the file.
let mut confirmed = String::new();
let mut failed = false;
let names = ["P0", "P1", "P2", "After 1", "After 2"];
for name in names {
rename(&mut server, &mut a, name);
store.changed(&id, &mut server);
if store.flush().contains(&id) {
failed = true;
break;
}
confirmed = name.to_string();
}
assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}");
// A crash, then the disk recovers.
drop(store);
fs::remove_dir(&blocker).unwrap();
let mut store = Store::open(dir.path(), c).unwrap();
let restored = store.open(&id).unwrap();
// Every confirmed change is there (the snapshot may also hold the one that was cut short).
let at = |n: &str| names.iter().position(|x| *x == n);
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
+42 -3
View File
@@ -91,7 +91,7 @@ pub fn key(dir: &Path) -> String {
}
pub fn live(s: &Session) -> bool {
s.live.status == Status::Live && s.live.syncing()
s.live().status == Status::Live && s.live().syncing()
}
/// `a` publishes its open file to the server at `addr`; the links.
@@ -99,8 +99,8 @@ pub fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
a.live.published.clone().unwrap()
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
a.live().published.clone().unwrap()
}
pub fn join(link: &str, name: &str) -> Session {
@@ -123,6 +123,45 @@ impl Raw {
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
}
/// Write just the header of a masked binary frame declaring `len` payload bytes: the server
/// refuses an oversized message from its header, before the client would push the payload
/// into a socket the server has already closed (a broken pipe on Linux).
pub fn send_header(&mut self, len: u64) {
let mut h = vec![0x82u8, 0x80 | 127];
h.extend_from_slice(&len.to_be_bytes());
h.extend_from_slice(&[1, 2, 3, 4]);
self.raw_write(&h);
}
/// Write raw bytes on the socket (a hand-made, partial frame).
pub fn raw_write(&mut self, bytes: &[u8]) {
use std::io::Write as _;
self.0.get_mut().write_all(bytes).unwrap();
}
/// The server closed the connection within `limit` (anything it sends meanwhile is skipped).
pub fn closed_within(&mut self, limit: Duration) -> bool {
use std::io::Read as _;
let s = self.0.get_mut();
let end = Instant::now() + limit;
let mut buf = [0u8; 4096];
let _ = s.set_read_timeout(Some(Duration::from_millis(200)));
while Instant::now() < end {
match s.read(&mut buf) {
Ok(0) => return true,
Ok(_) => {}
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
Err(_) => return true,
}
}
false
}
/// Send without waiting to be read (`Err` once the server has hung up).
pub fn try_send(&mut self, m: &ClientMsg) -> tungstenite::Result<()> {
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap())))
}
pub fn next(&mut self) -> ServerMsg {
loop {
if let Message::Binary(b) = self.0.read().unwrap() {
@@ -0,0 +1,40 @@
use figcraft_server::config::Config;
use figcraft_server::store::{FILES, Store};
#[test]
fn startup_problem_retention() {
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
assert!(!root.exists());
let result = (|| -> Result<(), String> {
for count in [3u32, 200, 201] {
let data = root.join(count.to_string());
for i in 0..count {
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
}
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
cfg.check()?;
let store = Store::open(&data, cfg.store())?;
let expected = (count as usize).min(200);
let problems = store.problems().to_vec();
let no_loaded_files = store.is_empty();
drop(store);
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
return Err("unexpected diagnostic or loaded file".into());
}
if problems.len() != expected {
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
}
// The latest problems stay, in scan order: the first folder's is the one dropped.
let first_kept = count as usize - expected;
for (n, p) in problems.iter().enumerate() {
let id = format!("{:016x}", first_kept + n);
if !p.contains(&id) {
return Err(format!("problem {n} should be about {id}: {p}"));
}
}
}
Ok(())
})();
std::fs::remove_dir_all(&root).unwrap();
assert!(result.is_ok(), "{}", result.unwrap_err());
}
+2 -1
View File
@@ -24,5 +24,6 @@ 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"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+70 -3
View File
@@ -40,7 +40,7 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
Ok(Box::new(WebShell { fig }))
}),
)
@@ -69,14 +69,35 @@ impl eframe::App for WebShell {
}
}
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
/// goes through egui's own web clipboard handling.
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
/// events with their text (a page can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
}
impl Services for WebServices {
fn downloads(&self) -> bool {
true
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
log::error!("couldn't download {name}: {e:?}");
false
}
}
}
fn clipboard_set_text(&mut self, s: &str) {
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba);
}
fn open_url(&mut self, url: &str) {
let Some(window) = web_sys::window() else { return };
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
@@ -90,4 +111,50 @@ impl Services for WebServices {
fn system_language(&self) -> Option<String> {
web_sys::window().and_then(|w| w.navigator().language())
}
/// The Gamepad API's controllers with the standard mapping (its button order is the layout's;
/// others are skipped). Browsers list controllers once one of their buttons is pressed.
fn gamepad_held(&mut self) -> u32 {
let Some(pads) = web_sys::window().and_then(|w| w.navigator().get_gamepads().ok()) else { return 0 };
let mut held = 0;
for pad in pads.iter().filter_map(|p| p.dyn_into::<web_sys::Gamepad>().ok()) {
if pad.mapping() != web_sys::GamepadMappingType::Standard {
continue;
}
for (i, b) in pad.buttons().iter().take(32).enumerate() {
if b.dyn_into::<web_sys::GamepadButton>().is_ok_and(|b| b.pressed()) {
held |= 1 << i;
}
}
}
held
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{
let o = web_sys::BlobPropertyBag::new();
o.set_type("application/octet-stream");
o
})?;
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?;
a.set_href(&url);
a.set_download(name);
body.append_child(&a)?;
a.click();
a.remove();
// Revoked later, not at once: some browsers cancel a download whose URL goes before it starts.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
// Without the timer the URL just stays until the page closes.
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS);
Ok(())
}
/// How long a download's object URL is kept (milliseconds).
const REVOKE_DOWNLOAD_MS: i32 = 40_000;
+13 -3
View File
@@ -26,10 +26,20 @@ webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
# The system's reduce-motion setting (UISettings.AnimationsEnabled) and interface language
# (GlobalizationPreferences.Languages).
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections"] }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# 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"
[dev-dependencies]
wat = { workspace = true }
[build-dependencies]
winresource = "0.1"
+6 -1
View File
@@ -1,4 +1,5 @@
//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere).
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
//! (no-op elsewhere).
fn main() {
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
@@ -14,6 +15,10 @@ fn main() {
if let Err(e) = res.compile() {
println!("cargo::warning=winresource: {e}");
}
// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers
// nest up to 256 deep in an export, and that recursion needs the same room everywhere.
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
}
let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();
println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");
+57
View File
@@ -0,0 +1,57 @@
//! macOS open-documents and quit Apple events (#289).
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft`
//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and
//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's
//! launch event that leaves winit's delegate alone, behind a safe main-thread API.
use std::path::PathBuf;
use figcraft_ui_egui::FigApp;
use fmv_macos_events::{Event, Inbox, Registration};
use serde_json::json;
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => open_documents(fig, paths),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
for p in paths {
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With,
//! a drop on the Dock icon) open as File › Open opens them (#289).
use std::path::PathBuf;
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, NoServices};
use serde_json::json;
use crate::apple_events::open_documents;
#[test]
fn documents_from_finder_open_and_leave_the_home_screen() {
let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("BLA-001.figcraft");
let mut s = Session::new();
s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
app.ui.home = true;
// A file that can't be read is reported and doesn't keep the others from opening.
open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]);
let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect();
assert_eq!(paths, [Some(file)]);
assert!(!app.ui.home, "the opened document shows");
assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error);
let _ = std::fs::remove_dir_all(&dir);
}
+77 -17
View File
@@ -1,7 +1,7 @@
//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -12,6 +12,20 @@ use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
type CtxSlot = Arc<Mutex<Option<egui::Context>>>;
const MAX_LINE: usize = 32 * 1024 * 1024;
const MAX_CONNECTIONS: usize = 8;
const MAX_QUEUED_REQUESTS: usize = 8;
const TIMEOUT: Duration = Duration::from_secs(60);
/// 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());
let mut queue = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
queue.retain(|request| !request.is_cancelled());
let count = queue.len().min(MAX_QUEUED_REQUESTS.saturating_sub(target.len()));
target.extend(queue.drain(..count));
}
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
@@ -23,9 +37,9 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
};
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in listener.incoming().flatten() {
let (q, c) = (queue.clone(), ctx.clone());
let _ = std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || serve(stream, q, c));
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
}
});
if let Err(e) = spawned {
@@ -33,37 +47,83 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
}
}
fn serve(stream: TcpStream, queue: Queue, ctx: CtxSlot) {
let Ok(read_half) = stream.try_clone() else { return };
let mut out = stream;
let reader = BufReader::new(read_half);
for line in reader.lines() {
let Ok(line) = line else { break };
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, 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);
}) {
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<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(out.try_clone()?);
let mut line = Vec::new();
loop {
line.clear();
if (&mut reader).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
break;
}
if line.last() == Some(&b'\n') {
line.pop();
}
if line.len() > MAX_LINE {
writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
break;
}
let Ok(line) = std::str::from_utf8(&line) else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
let reply = match serde_json::from_str::<Value>(line) {
Ok(v) => {
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!({}));
let (tx, rx) = std::sync::mpsc::channel();
queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(ControlRequest { id: id.clone(), method, params, reply: tx });
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
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 {
drop(pending);
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() });
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(Duration::from_secs(60)) {
match rx.recv_timeout(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(_) => json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}),
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
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(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
Err(e) => {
// A line that isn't JSON ends the connection: a web page can make the browser send
// an HTTP request here, and its header lines must not let the JSON lines of its body
// through as commands.
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
break;
}
};
if writeln!(out, "{reply}").is_err() {
break;
}
writeln!(out, "{reply}")?;
}
Ok(())
}
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;
+278
View File
@@ -0,0 +1,278 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::io::Read;
use std::net::Shutdown;
use std::sync::mpsc;
use std::time::Instant;
fn pair() -> (TcpStream, TcpStream) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
client.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
(client, listener.accept().unwrap().0)
}
fn queue() -> Queue {
Arc::new(Mutex::new(Vec::new()))
}
fn context() -> CtxSlot {
Arc::new(Mutex::new(None))
}
fn reply(reader: &mut BufReader<TcpStream>) -> Value {
let mut line = String::new();
assert!(reader.read_line(&mut line).unwrap() > 0);
serde_json::from_str(&line).unwrap()
}
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
}
#[test]
fn rejects_an_oversized_unterminated_frame_without_dispatch() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
// Generate bytes as they are written; no giant input string and no terminating newline.
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "message too large"}));
assert_eq!(reader.read(&mut [0]).unwrap(), 0);
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty());
}
#[test]
fn valid_frames_keep_their_order_ids_and_parameters() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
let mut reader = BufReader::new(client);
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
assert_eq!(r.id, "test");
assert_eq!(r.method, "engine.execute");
assert_eq!(r.params, json!({"command": "shape.rectangle"}));
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
client.write_all(http.as_bytes()).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
}
#[test]
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
let (mut client, server) = pair();
let pending = queue();
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| request()));
let mut ui_queue = (0..MAX_QUEUED_REQUESTS - 1).map(|_| request()).collect();
drain_queue(&pending, &mut ui_queue);
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS - 1);
pending.lock().unwrap().push(request());
drain_queue(&pending, &mut ui_queue);
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
ui_queue.clear();
drain_queue(&pending, &mut ui_queue);
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
let (mut client, server) = pair();
let (done, finished) = mpsc::channel();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
client.write_all(bytes).unwrap();
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
assert!(pending.lock().unwrap().is_empty());
worker.join().unwrap().unwrap();
}
}
#[test]
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let pending = queue();
let ctx = context();
let mut workers = Vec::new();
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));
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));
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
assert_eq!(workers.len(), MAX_CONNECTIONS);
for client in clients {
client.shutdown(Shutdown::Both).unwrap();
}
let start = Instant::now();
while workers.iter().any(|worker| !worker.is_finished()) {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
let (mut client, server) = pair();
spawn_connection(server, pending, ctx, &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);
client.shutdown(Shutdown::Both).unwrap();
workers.pop().unwrap().join().unwrap();
}
#[test]
fn a_client_that_stops_reading_cannot_hold_a_worker() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let (done, finished) = mpsc::channel();
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!("x".repeat(8 * 1024 * 1024))) }).unwrap();
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
worker.join().unwrap().unwrap();
}
#[test]
fn a_limit_sized_frame_is_accepted_at_eof() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
client.write_all(message).unwrap();
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
client.shutdown(Shutdown::Write).unwrap();
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
assert_eq!(r.id, 1);
assert_eq!(r.method, "ui.inspect");
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
assert_eq!(reply(&mut BufReader::new(client)), json!({"id": 1, "ok": true, "result": {}}));
worker.join().unwrap().unwrap();
}
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let mut app = FigApp::new(figcraft_engine::Session::new(), Box::new(NoServices));
let window = windows::open(&mut app, &ctx, Some(0), None, None).unwrap();
let pending = queue();
let mut clients = Vec::new();
let mut workers = Vec::new();
for id in 0..MAX_QUEUED_REQUESTS {
let (mut client, server) = pair();
let q = pending.clone();
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
clients.push(BufReader::new(client));
}
let start = Instant::now();
while pending.lock().unwrap().len() < MAX_QUEUED_REQUESTS {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
drain_queue(&pending, &mut app.control_queue);
control::drain(&mut app, &ctx);
assert_eq!(app.control_queue.len(), MAX_QUEUED_REQUESTS, "the other window has not drawn");
for client in &mut clients {
assert!(reply(client)["error"].as_str().unwrap().contains("timed out"));
client.get_ref().shutdown(Shutdown::Write).unwrap();
}
assert!(app.control_queue.iter().all(ControlRequest::is_cancelled));
let expired = app.control_queue.pop().unwrap();
// 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
}));
let (mut client, server) = pair();
let q = pending.clone();
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
let start = Instant::now();
while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
drain_queue(&pending, &mut app.control_queue);
assert_eq!(app.control_queue.len(), 1, "expired UI requests no longer occupy the cap");
control::drain(&mut app, &ctx);
let mut client = BufReader::new(client);
assert_eq!(reply(&mut client)["ok"], true);
client.get_ref().shutdown(Shutdown::Write).unwrap();
assert!(app.control_queue.is_empty());
// A window that finally draws must discard its expired command before dispatch, too.
let documents = app.session.docs.len();
app.windows.current = window;
app.control_queue.push(expired);
control::drain(&mut app, &ctx);
assert!(app.control_queue.is_empty());
assert_eq!(app.session.docs.len(), documents);
for worker in workers {
worker.join().unwrap().unwrap();
}
}
+50
View File
@@ -0,0 +1,50 @@
//! Controllers for the prototype player's key/gamepad triggers: the buttons held, as bits at their
//! standard gamepad layout indices (`figcraft_doc::proto::GAMEPAD_BUTTONS`).
//! Windows reads Windows.Gaming.Input (Xbox-compatible controllers, through the `windows` crate the
//! app already uses). macOS, Linux and FreeBSD report none yet: the usual Rust controller library
//! links the system's libudev on Linux, which the pure-Rust builds avoid.
/// The standard layout's buttons held on any controller (bit i: index i).
#[cfg(target_os = "windows")]
pub fn held() -> u32 {
use windows::Gaming::Input::{Gamepad, GamepadButtons as B};
// The standard index of each button Windows reports; the triggers are analog (down past halfway).
const MAP: [(B, u32); 14] = [
(B::A, 0),
(B::B, 1),
(B::X, 2),
(B::Y, 3),
(B::LeftShoulder, 4),
(B::RightShoulder, 5),
(B::View, 8),
(B::Menu, 9),
(B::LeftThumbstick, 10),
(B::RightThumbstick, 11),
(B::DPadUp, 12),
(B::DPadDown, 13),
(B::DPadLeft, 14),
(B::DPadRight, 15),
];
let Ok(pads) = Gamepad::Gamepads() else { return 0 };
let mut held = 0;
for pad in pads {
let Ok(r) = pad.GetCurrentReading() else { continue };
for (b, i) in MAP {
if r.Buttons.contains(b) {
held |= 1 << i;
}
}
if r.LeftTrigger > 0.5 {
held |= 1 << 6;
}
if r.RightTrigger > 0.5 {
held |= 1 << 7;
}
}
held
}
#[cfg(not(target_os = "windows"))]
pub fn held() -> u32 {
0
}
+34
View File
@@ -0,0 +1,34 @@
//! Which graphics API draws the window.
//!
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
//! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
//!
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
//! systems other than Windows keep eframe's defaults.
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
use eframe::wgpu::Backends;
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
/// on Windows, else eframe's `defaults`.
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
match requested {
Some(requested) => requested,
None if windows => WINDOWS,
None => defaults,
}
}
/// Offer wgpu [`backends`] instead of eframe's defaults.
pub fn configure(options: &mut WgpuConfiguration) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
let offered = &mut create.instance_descriptor.backends;
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
}
}
+32
View File
@@ -0,0 +1,32 @@
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
//! driver crashed the app as its window opened), and nothing changed anywhere else.
use eframe::wgpu::Backends;
use crate::graphics::backends;
/// What eframe offers when `WGPU_BACKEND` isn't set.
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
#[test]
fn windows_is_offered_direct3d_and_not_vulkan() {
let offered = backends(None, true, DEFAULTS);
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
}
#[test]
fn wgpu_backend_names_the_backend_on_every_system() {
for windows in [true, false] {
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
}
}
}
#[test]
fn other_systems_keep_eframes_defaults() {
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
}
+73 -11
View File
@@ -9,15 +9,30 @@
#![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")]
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
mod control_server;
mod gamepad;
mod graphics;
mod recovery;
mod services;
mod startup;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
#[cfg(test)]
mod args_tests;
#[cfg(test)]
mod graphics_tests;
#[cfg(test)]
mod recovery_online_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod services_tests;
#[cfg(test)]
mod startup_tests;
use std::sync::{Arc, Mutex};
@@ -35,6 +50,9 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
/// Documents and quit requests from Finder, the Dock and Open With.
#[cfg(target_os = "macos")]
apple_events: fmv_macos_events::Inbox,
}
impl eframe::App for App {
@@ -46,9 +64,10 @@ impl eframe::App for App {
}
}
let title = with_app(|fig| {
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
fig.control_queue.extend(q.drain(..));
drop(q);
control_server::drain_queue(&self.queue, &mut fig.control_queue);
figcraft_ui_egui::control::drain_hidden(fig, 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
// windows are open).
figcraft_ui_egui::windows::on_close_request(fig, ctx);
@@ -75,9 +94,9 @@ impl eframe::App for App {
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)));
services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))));
}
fn on_exit(&mut self) {
@@ -126,22 +145,37 @@ fn main() -> eframe::Result {
}
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
let recovery: recovery::Shared = Arc::new(Mutex::new(recovery_root.as_deref().and_then(recovery::Recovery::start)));
// 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());
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
let recovered = recovery_root.as_deref().map(|r| recovery::recover_all(r, &mut session)).unwrap_or_default();
let mut copies = recovery_root.as_deref().and_then(recovery::Recovery::start);
// Copies of online files stay until their files arrive from their servers.
if let Some(r) = copies.as_mut() {
r.adopt(recovered.waiting);
}
*recovery::lock(&recovery) = copies;
// Plugins installed from a server's plugin directory live with the user's settings.
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
// Invite links open this app (Windows registers per user at run time; installed builds by
// default, never a build from source unless asked).
session.live.links = figcraft_collab_net::links::system();
session.invite_links_startup();
if session.docs.is_empty() {
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
// tips and templates; later runs start in a new file.
let first_run = svc.prefs.is_none() && args.join.is_none();
if session.docs.is_empty() && !first_run {
let _ = session.execute("file.new", &json!({}));
}
// The interface language from the first frame on, and for the message when the window can't
// 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.ui.home = false;
fig.note_recovered(recovered);
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
}
@@ -150,8 +184,11 @@ fn main() -> eframe::Result {
control_server::start(p, queue.clone(), ctx_slot.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
// and StartupWMClass (FreeBSD's package matches the binary's name instead).
let mut viewport = egui::ViewportBuilder::default()
.with_title("FigCraft")
.with_app_id("ai.storyteller.figcraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
@@ -167,7 +204,32 @@ fn main() -> eframe::Result {
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
Ok(Box::new(App {
queue,
ctx_slot,
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
}))
}),
);
// Say why instead of quitting silently: release builds on Windows have no console.
if let Err(e) = &result {
startup::failed(e, port.is_some());
}
result
}
+276 -52
View File
@@ -4,7 +4,20 @@
//! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
//! whose lock is free belong to runs that ended without closing: their copies open again as
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of
//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run
//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder
//! with its last copy. Closing the app normally removes its own.
//!
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
//! after the copy it describes): the server, the file's id, and who the app was in the file with
//! the last change of the server's it had; never a token. The next start joins that file again
//! as its owner (`collab.recoverFile`) and **keeps the copy until the outcome is known**: the
//! server has everything in it (the copy goes), the copy's own unconfirmed changes were put on
//! top of the server's file and sent, the copy was opened beside the file because both had
//! changed, or the file couldn't be joined and the copy was opened as any other. Whatever a copy
//! became is copied into this run's folder before the old copy is removed. Without the note, or
//! when the file is no longer recorded, the copy opens as any other.
use std::collections::HashMap;
use std::fs::File;
@@ -12,7 +25,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use figcraft_engine::Session;
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
/// Seconds between copies of a document that keeps changing.
@@ -31,77 +45,219 @@ pub struct Recovery {
dir: PathBuf,
/// Held while the app runs: a free lock means the run that wrote the folder is gone.
_lock: File,
/// The revision each document was last copied at, and the write in flight.
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
/// What was copied of each document, and the write in flight.
written: HashMap<u64, Copy>,
/// When (UI clock) the next copies are due.
next: f64,
/// Copies of online files from earlier runs, kept until their outcome is known.
waiting: Vec<Waiting>,
}
/// The state of the copy of one document: only a write that succeeded counts as a copy.
#[derive(Default)]
struct Copy {
/// The [`stamp`] whose copy was written.
done: Option<Vec<u64>>,
/// The write in flight: the stamp it copies, and whether it succeeded once it ends.
flight: Option<(Vec<u64>, JoinHandle<bool>)>,
}
impl Copy {
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
fn reap(&mut self) {
if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) {
self.join();
}
}
/// Wait for the write in flight and take in its result.
fn join(&mut self) {
if let Some((stamp, h)) = self.flight.take()
&& h.join().unwrap_or(false)
{
self.done = Some(stamp);
}
}
/// Whether `stamp` has neither been copied nor is being copied.
fn pending(&self, stamp: &[u64]) -> bool {
self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp)
}
}
/// A copy from an earlier run, kept until what it became is safe.
pub struct Waiting {
until: Until,
copy: PathBuf,
/// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy
/// left directly in the root by older versions).
_lock: Option<Arc<File>>,
}
/// What a kept copy waits for.
enum Until {
/// One of your online files, joined again as this session: its outcome.
Session(u64),
/// Opened as document `uid`: this run's own copy of it (or its save or close).
Copied(u64),
}
/// What [`recover_all`] brought back.
#[derive(Default)]
pub struct Recovered {
/// Documents opened from their copies.
pub copies: usize,
/// The copies kept until what they became is safe ([`Recovery::adopt`]).
pub waiting: Vec<Waiting>,
}
impl Recovery {
/// Take this run's folder under `root` (`None` when it can't be created or locked: no copies).
/// A crashed run with the same process id may still hold its folder (its copies kept): then
/// another name is taken.
pub fn start(root: &Path) -> Option<Self> {
let dir = root.join(std::process::id().to_string());
std::fs::create_dir_all(&dir).ok()?;
let lock = File::create(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0 })
let pid = std::process::id();
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
/// Keep earlier runs' copies until what they became is safe.
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
self.waiting.extend(waiting);
}
/// Copy the documents that changed since their last copy and drop the copies of documents that
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
/// an idle window can sleep).
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
self.written.values_mut().for_each(Copy::reap);
self.settle(s);
// A join on its way settles within its timeouts: look again soon. A copy waiting for this
// run's own is looked at again with the next copies.
let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_)));
let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) };
if now < self.next {
return pending.then_some(self.next - now);
return self.pending(s).then_some(self.next - now).or(settling);
}
self.write(s, false);
self.next = now + INTERVAL;
None
// A copy that failed is tried again at the next turn, without another edit.
settling.or(self.pending(s).then_some(INTERVAL))
}
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
fn pending(&self, s: &Session) -> bool {
s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d))))
}
/// Remove earlier runs' copies once what they became is safe: copied here (the write
/// succeeded), saved or closed. An online file's copy goes once its outcome is known and every
/// document is safe, since which of them it became isn't known.
pub fn settle(&mut self, s: &Session) {
let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id));
if self.waiting.iter().any(joined) {
self.write(s, true);
}
let all_safe = s.docs.iter().all(|d| self.safe(s, d));
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until {
Until::Session(_) => joined(w) && all_safe,
Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)),
});
self.waiting = waiting;
done.into_iter().for_each(remove_kept);
}
/// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write
/// that succeeded) with no write in flight.
fn safe(&self, s: &Session, d: &DocState) -> bool {
!needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none())
}
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
pub fn write(&mut self, s: &Session, wait: bool) {
for d in &s.docs {
let path = self.dir.join(format!("{}.figcraft", d.uid));
if !needs_copy(d) {
if self.written.remove(&d.uid).is_some() {
let note = path.with_extension(ONLINE);
if !needs_copy(s, d) {
if let Some(mut w) = self.written.remove(&d.uid) {
// A write still running would bring the copy back after it was removed.
w.join();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&note);
}
continue;
}
let entry = self.written.entry(d.uid).or_insert((0, None));
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
if entry.0 == d.revision || busy {
let entry = self.written.entry(d.uid).or_default();
// An emergency save must copy the newest revision: let the write in flight finish first.
if wait {
entry.join();
} else {
entry.reap();
}
// A write still in flight (for any revision) is waited for; a failed one is tried again.
let stamp = stamp(s, d);
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
continue;
}
let doc = d.doc.clone();
let job = move || write_atomic(&path, &doc);
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
// Which online file it is and what of the server's this very copy has (a line each:
// the server, the file's id, then the session, client id and last change), while it
// is one. Written after the copy, never before: a note must not promise more of the
// server's changes than its copy holds.
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
let job = move || {
if !write_atomic(&path, &doc, &versions, &branches) {
return false;
}
let _ = match online {
Some(text) => std::fs::write(&note, text),
None => std::fs::remove_file(&note),
};
true
};
// The stamp counts as copied only once its write succeeded.
if wait {
job();
entry.1 = None;
if job() {
entry.done = Some(stamp);
}
} else {
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h));
}
entry.0 = d.revision;
}
// Closed documents.
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
let dir = &self.dir;
self.written.retain(|uid, _| {
self.written.retain(|uid, w| {
let keep = open.contains(uid);
if !keep {
w.join();
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
}
keep
});
}
/// A normal quit: the documents were saved or their changes discarded on purpose.
/// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`.
#[cfg(test)]
pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle<bool>) {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
let Self { dir, _lock, written, .. } = self;
let Self { dir, _lock, written, waiting, .. } = self;
// The documents earlier runs' copies became were saved or discarded too.
waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept);
// Windows removes a folder only once nothing in it is open: copies being written first.
for h in written.into_values().filter_map(|w| w.1) {
for (_, h) in written.into_values().filter_map(|w| w.flight) {
let _ = h.join();
}
drop(_lock);
@@ -109,35 +265,96 @@ impl Recovery {
}
}
/// A document whose changes would be lost (a branch tab's edits are in its main file).
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
d.is_dirty() && d.branch_of.is_none()
}
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return };
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
/// Remove a kept copy and its note, and its folder with its last copy (while another is kept
/// there, its lock is held).
fn remove_kept(w: Waiting) {
let _ = std::fs::remove_file(&w.copy);
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1);
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last);
drop(w);
if let Some(dir) = dir {
let _ = std::fs::remove_file(dir.join(LOCK));
let _ = std::fs::remove_dir(dir);
}
}
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
/// A copy that can't be read stays where it is. Returns how many documents came back.
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
/// file isn't recorded any more, or is open already): then it opens as any other copy.
fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
let note = std::fs::read_to_string(file.with_extension(ONLINE)).ok()?;
let mut lines = note.lines().map(str::trim);
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
fn needs_copy(s: &Session, d: &DocState) -> bool {
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
}
/// The tabs showing branches of the file in tab `uid`.
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
}
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
/// only its own revision). A new copy is due when they change.
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
}
/// Write `doc` with its version history and branches to `path` through a temporary file; false
/// when it couldn't be written (the copy before it is still there).
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
}
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
/// unsaved copies.
#[cfg(test)]
pub fn recover(root: &Path, s: &mut Session) -> usize {
let Ok(entries) = std::fs::read_dir(root) else { return 0 };
let mut n = 0;
let mut open = |file: &Path, s: &mut Session| {
if file.extension().is_some_and(|e| e == "figcraft") && s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
n += 1;
let _ = std::fs::remove_file(file);
recover_all(root, s).copies
}
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept
/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your
/// online files, while its file is joined again. A copy that can't be read stays where it is.
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
let mut out = Recovered::default();
let Ok(entries) = std::fs::read_dir(root) else { return out };
let mut open = |file: &Path, lock: Option<&Arc<File>>, s: &mut Session| {
if file.extension().is_none_or(|e| e != "figcraft") {
return;
}
// One of your online files comes back from its server, its copy kept until then.
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) });
return;
}
let before: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
out.copies += 1;
if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) {
out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() });
}
}
};
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
open(&path, s);
open(&path, None, s);
continue;
}
// Still running (another window of the app), or not ours to take.
@@ -145,19 +362,26 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
if lock.try_lock().is_err() {
continue;
}
let lock = Arc::new(lock);
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
if f.extension().is_some_and(|e| e == "tmp") {
// A copy cut off mid-write; the one before it is still there.
let _ = std::fs::remove_file(f);
} else {
open(f, s);
open(f, Some(&lock), s);
}
}
drop(lock);
// Empty now unless a copy couldn't be read (kept for the next start).
let _ = std::fs::remove_file(path.join(LOCK));
let _ = std::fs::remove_dir(&path);
// The notes go with their copies (one whose copy stays, stays with it).
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
let _ = std::fs::remove_file(f);
}
// Empty now unless a copy couldn't be read, or is kept (its lock held).
if Arc::strong_count(&lock) == 1 {
drop(lock);
let _ = std::fs::remove_file(path.join(LOCK));
let _ = std::fs::remove_dir(&path);
}
}
n
out
}
+379
View File
@@ -0,0 +1,379 @@
//! Recovery of your online files never loses an edit. The copy of a document that was one of them
//! when a run ended without closing has a note beside it (the server, the file, who the app was
//! there and the server's last change it had; never a token). The next start joins the file again
//! as its owner and keeps the copy until the outcome is known: the copy is ahead of the server
//! (its changes are applied on top and sent, as the client the app was), the same (it goes), both
//! changed (it opens beside the file as a recovered copy), or the server can't be reached (it
//! opens as any other copy). Without the record the copy opens as any other.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_engine::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Resume, Role, SessionInfo, Status};
use serde_json::json;
use crate::recovery::{Recovery, recover, recover_all};
const SERVER: &str = "wss://share.example/";
const OWNER: &str = "OwnerSecretOwnerSecret";
const EDIT: &str = "figcraft://join?server=wss://share.example/&file=f1&token=EditSecretEditSecret00";
/// What the link was asked, what the app sent, and the events handed out next (as the session
/// started last).
#[derive(Default)]
struct Script {
joined: Vec<(String, Option<Resume>)>,
sent: Vec<ClientMsg>,
events: Vec<Event>,
session: u64,
}
struct Link(Arc<Mutex<Script>>);
impl LiveLink for Link {
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, session: u64, link: &str, name: &str) -> Result<(), String> {
self.join_resuming(session, link, name, None)
}
fn join_resuming(&mut self, session: u64, link: &str, _: &str, resume: Option<Resume>) -> Result<(), String> {
let mut s = self.0.lock().unwrap();
s.session = session;
s.joined.push((link.to_string(), resume));
Ok(())
}
fn publish(&mut self, session: u64, _: PublishOptions) -> Result<(), String> {
self.0.lock().unwrap().session = session;
Ok(())
}
fn leave(&mut self, _: u64) {}
fn presence(&mut self, _: u64, _: &Presence) {}
fn send(&mut self, _: u64, msg: ClientMsg) {
self.0.lock().unwrap().sent.push(msg);
}
fn poll(&mut self) -> Vec<(u64, Event)> {
let mut s = self.0.lock().unwrap();
let session = s.session;
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
}
}
type Shared = Arc<Mutex<Script>>;
fn session() -> (Session, Shared) {
let mut s = Session::new();
let script = Shared::default();
s.live.install(Box::new(Link(script.clone())));
(s, script)
}
fn root(name: &str) -> PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-recovery-online-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
r
}
fn info() -> SessionInfo {
SessionInfo {
role: Role::Guest,
me: 1,
name: "Me".into(),
color: "#0D99FF".into(),
file_name: "Homepage".into(),
pages: vec![],
link: EDIT.into(),
url: String::new(),
address: SERVER.into(),
network: true,
access: Access::Edit,
}
}
/// Someone joins `server` (as the client `resume` names, if it knows it): their id and welcome.
fn join(server: &mut Server, resume: Option<Resume>) -> (u32, ServerMsg) {
let ClientMsg::Hello(mut hello) = Client::hello("f1", "", "Me", "") else { panic!("no hello") };
hello.resume = resume;
let (id, outs) = server.join(hello).unwrap();
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
(id, welcome)
}
/// What the app sent since the last call reaches the server; whether every batch was accepted.
/// The server's confirmations wait for the app's next poll.
fn deliver(script: &Shared, server: &mut Server, from: u32) -> bool {
let sent = std::mem::take(&mut script.lock().unwrap().sent);
let mut accepted = true;
for m in sent {
for o in server.receive(from, m) {
match o {
Outgoing::To(_, ServerMsg::Reject { .. }) => accepted = false,
Outgoing::All(m @ ServerMsg::Changes { .. }) => script.lock().unwrap().events.push(Event::Sync(m)),
_ => {}
}
}
}
accepted
}
fn layers(doc: &figcraft_engine::doc::Document) -> usize {
doc.node(doc.pages[0]).map_or(0, |p| p.children.len())
}
/// A run whose open file was shared online: the app, what it sent, the server and the app's id
/// there. One rectangle is on the server.
fn shared() -> (Session, Shared, Server, u32) {
let (mut s, script) = session();
s.execute("pref.publishServer", &json!({"url": SERVER})).unwrap();
s.execute("file.rename", &json!({"name": "Homepage"})).unwrap();
s.execute("collab.share", &json!({})).unwrap();
let published = Published {
server: "share.example".into(),
file: "f1".into(),
name: "Homepage".into(),
edit: EDIT.into(),
view: String::new(),
owner: Some(OWNER.into()),
expires: None,
};
let mut server = Server::new(s.doc().unwrap().clone(), "", Limits::default());
let (me, welcome) = join(&mut server, None);
script.lock().unwrap().events.extend([Event::Published(published), Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
s.live_poll();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(deliver(&script, &mut server, me));
s.live_poll();
assert_eq!(layers(server.document()), 1);
(s, script, server, me)
}
/// The run ends without closing, its copies written; the folder it leaves (as another process's).
fn crash(s: &Session, root: &Path) -> PathBuf {
let mut r = Recovery::start(root).unwrap();
r.write(s, true);
drop(r);
let left = root.join("crashed");
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
left
}
fn copies(dir: &Path, ext: &str) -> usize {
std::fs::read_dir(dir).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == ext)).count())
}
/// The next run: only the preferences survive. Its copies of online files wait for their files.
fn restart(crashed: &Session, root: &Path) -> (Session, Shared, Recovery, usize) {
let (mut next, script) = session();
next.prefs = serde_json::from_value(serde_json::to_value(&crashed.prefs).unwrap()).unwrap();
let recovered = recover_all(root, &mut next);
let mut r = Recovery::start(root).unwrap();
r.adopt(recovered.waiting);
(next, script, r, recovered.copies)
}
/// The file arrives in the next run from `server`, as the join asked.
fn arrive(next: &mut Session, script: &Shared, server: &mut Server) -> u32 {
let resume = script.lock().unwrap().joined.last().and_then(|j| j.1);
let (id, welcome) = join(server, resume);
script.lock().unwrap().events.extend([Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
next.live_poll();
id
}
#[test]
fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
let root = root("ahead");
let (mut s, script, mut server, me) = shared();
// An edit the server never got (the connection was down, or the app died first).
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
let at = s.online_of(s.docs[0].uid).unwrap().2;
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
let (mut next, script, mut r, opened) = restart(&s, &root);
assert_eq!(opened, 0, "nothing comes back as a plain copy");
{
let sc = script.lock().unwrap();
let [(link, resume)] = sc.joined.as_slice() else { panic!("joined {}", sc.joined.len()) };
assert_eq!(link, EDIT, "joined again as its owner");
assert_eq!(*resume, Some(Resume { seq: u64::MAX, ..at }), "as the client it was, asking for the whole file");
}
// The copy stays until the outcome is known.
assert!(r.tick(&next, 0.0).is_some(), "it looks again while the file is on its way");
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1));
// The file arrives: the server lacks the ellipse; it goes on top as one edit and is sent.
let id = arrive(&mut next, &script, &mut server);
assert_eq!(id, me, "the server gave the id back");
assert_eq!(next.message.take().as_deref(), Some("Recovered your changes to Homepage that hadn't reached the server"));
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "in the file's own tab, no copy beside it");
assert!(deliver(&script, &mut server, id), "the server takes it (new layers are that client's to create)");
assert_eq!(layers(server.document()), 2, "everyone gets the edit");
assert_eq!(next.execute("collab.peers", &json!({})).unwrap()["status"], "live");
// Only now the old copy goes, after this run has copied what it became.
r.tick(&next, 1.0);
assert!(!left.exists(), "the crashed run's folder is gone");
let mine = root.join(std::process::id().to_string());
assert_eq!((copies(&mine, "figcraft"), copies(&mine, "online")), (1, 1), "covered by this run's own copy");
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_online_copy_stays_while_what_it_became_cant_be_copied() {
let root = root("ahead-blocked");
let (mut s, script, mut server, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
let (mut next, script, mut r, _) = restart(&s, &root);
arrive(&mut next, &script, &mut server);
assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet");
// Something is in the way of this run's copy: writing it fails.
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid));
std::fs::create_dir_all(&mine).unwrap();
r.tick(&next, 1.0);
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk");
// Once it can be written, the old copy goes.
std::fs::remove_dir(&mine).unwrap();
next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap();
r.tick(&next, 2.0);
assert!(!left.exists());
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_the_server_already_has_is_discarded() {
let root = root("equal");
let (mut s, script, mut server, me) = shared();
// Sent and accepted, but the app died before it heard so.
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(deliver(&script, &mut server, me));
let left = crash(&s, &root);
let (mut next, script, mut r, _) = restart(&s, &root);
assert_eq!(copies(&left, "figcraft"), 1);
arrive(&mut next, &script, &mut server);
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "the file as the server has it");
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing to send");
assert!(!next.docs[1].is_dirty());
r.tick(&next, 1.0);
assert!(!left.exists());
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_that_cant_be_merged_opens_beside_the_file() {
let root = root("conflict");
let (mut s, script, mut server, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
// Meanwhile someone else changes the file on the server.
let (other, welcome) = join(&mut server, None);
let mut them = Client::join(welcome).unwrap();
let mut doc = them.document().clone();
let page = doc.pages[0];
doc.create(page, figcraft_engine::doc::NodeKind::Rectangle, "Theirs", 50.0, 50.0, 5.0, 5.0).unwrap();
let batch = them.edit(&doc).unwrap().unwrap();
assert!(!server.receive(other, batch).iter().any(|o| matches!(o, Outgoing::To(_, ServerMsg::Reject { .. }))));
let (mut next, script, mut r, _) = restart(&s, &root);
arrive(&mut next, &script, &mut server);
// The server's file is untouched; the copy is a tab of its own, named as what it is.
let names: Vec<&str> = next.docs.iter().map(|d| d.doc.name.as_str()).collect();
assert_eq!(names, ["Untitled", "Homepage", "Homepage (recovered copy)"]);
assert_eq!(next.active, 1, "the file stays in front");
assert_eq!(layers(&next.docs[1].doc), 2, "the rectangle and theirs");
assert_eq!(layers(&next.docs[2].doc), 2, "the rectangle and the ellipse that never got there");
assert!(next.docs[2].is_dirty() && next.docs[2].path.is_none());
assert_eq!(
next.message.take().as_deref(),
Some("Homepage changed on the server before your last changes reached it: they are open beside it as “Homepage (recovered copy)”")
);
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing is forced onto the server");
r.tick(&next, 1.0);
assert!(!left.exists());
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1, "the recovered tab has this run's copy");
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_whose_server_cant_be_reached_opens_as_any_other() {
let root = root("offline");
let (mut s, script, _, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
let (mut next, script, mut r, _) = restart(&s, &root);
r.tick(&next, 0.0);
assert_eq!(copies(&left, "figcraft"), 1, "kept while the join is on its way");
script.lock().unwrap().events.push(Event::Status(Status::Failed { reason: "Couldn't reach the server (timed out)".into() }));
next.live_poll();
let ds = next.docs.last().unwrap();
assert_eq!((ds.doc.name.as_str(), layers(&ds.doc), ds.is_dirty(), ds.path.is_none()), ("Homepage", 2, true, true));
assert_eq!(next.active, next.docs.len() - 1);
assert!(next.message.take().unwrap_or_default().contains("your recovered copy is open"));
assert_eq!(next.prefs.online_files.len(), 1, "the record stays: the file is still yours on the server");
r.tick(&next, 1.0);
assert!(!left.exists());
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1);
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_server_that_forgot_the_client_gets_no_layers_it_would_refuse() {
let root = root("forgot");
let (mut s, script, _, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
crash(&s, &root);
// The server lost its session (restored from a backup): the same file, another session, so
// the app joins as someone new and can't create the layers of who it was.
let mut restored = Server::new(Session::new().doc().unwrap().clone(), "", Limits::default());
let (mut next, script, r, _) = restart(&s, &root);
arrive(&mut next, &script, &mut restored);
assert_eq!(next.docs.last().map(|d| d.doc.name.as_str()), Some("Homepage (recovered copy)"));
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn without_its_record_the_copy_opens_as_any_other() {
let root = root("fallback");
let (s, _, _, _) = shared();
crash(&s, &root);
// The record was forgotten (or the preferences lost): the copy is what there is.
let (mut next, script) = session();
next.docs.clear();
assert_eq!(recover(&root, &mut next), 1);
assert!(script.lock().unwrap().joined.is_empty());
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_note_follows_its_copy_and_goes_when_the_file_leaves_its_session() {
let root = root("note");
let (mut s, _, _, _) = shared();
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let dir = root.join(std::process::id().to_string());
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 1));
// Left: the copy is an ordinary one from the next write on.
s.execute("collab.leave", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 0));
r.finish();
assert!(std::fs::read_dir(&root).unwrap().next().is_none());
let _ = std::fs::remove_dir_all(&root);
}
+288 -2
View File
@@ -4,7 +4,7 @@
use figcraft_engine::Session;
use serde_json::json;
use crate::recovery::{Recovery, recover};
use crate::recovery::{Recovery, recover, recover_all};
fn root(name: &str) -> std::path::PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
@@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() {
let ds = next.docs.first().unwrap();
assert!(ds.is_dirty() && ds.path.is_none());
assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1));
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
let _ = std::fs::remove_dir_all(&root);
}
@@ -62,3 +62,289 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
assert!(!dir.exists(), "a normal quit removes the copies");
let _ = std::fs::remove_dir_all(&root);
}
/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as
/// another process's).
fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf {
let mut r = Recovery::start(root).unwrap();
r.write(s, true);
drop(r);
let left = root.join("crashed");
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
left
}
fn copies_in(d: &std::path::Path) -> usize {
std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count())
}
/// The next start: the copies come back, and this run's recovery keeps them until it covers them.
fn restart(root: &std::path::Path) -> (Session, Recovery) {
let mut next = Session::empty();
let recovered = recover_all(root, &mut next);
assert_eq!(recovered.copies, 1);
let mut r = Recovery::start(root).unwrap();
r.adopt(recovered.waiting);
(next, r)
}
#[test]
fn a_recovered_copy_stays_until_this_run_has_its_own_copy() {
let root = root("handoff");
let left = crashed(&edited(), &root);
let (next, mut r) = restart(&root);
assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one");
r.write(&next, true);
r.tick(&next, 100.0);
assert!(!left.exists(), "covered by this run's copy, the old one goes");
assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1);
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recovered_copy_stays_while_its_replacement_cant_be_written() {
let root = root("blocked");
let left = crashed(&edited(), &root);
let (next, mut r) = restart(&root);
// Something is in the way of this run's copy: writing it fails.
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid));
std::fs::create_dir_all(&mine).unwrap();
r.write(&next, true);
r.tick(&next, 100.0);
assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk");
// Once it can be written, the old copy goes.
std::fs::remove_dir(&mine).unwrap();
let mut next = next;
next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap();
r.write(&next, true);
r.tick(&next, 200.0);
assert!(!left.exists());
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() {
let root = root("nocopies");
crashed(&edited(), &root);
let mut next = Session::empty();
let recovered = recover_all(&root, &mut next);
assert_eq!(recovered.copies, 1);
// This run couldn't take a folder of its own: it ends (or crashes) with nothing copied.
drop(recovered);
let mut after = Session::empty();
assert_eq!(recover(&root, &mut after), 1, "the work comes back again");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() {
for close in [false, true] {
let root = root(if close { "closed" } else { "saved" });
let left = crashed(&edited(), &root);
let (mut next, mut r) = restart(&root);
if close {
next.docs.clear();
} else {
next.ds_mut().unwrap().mark_saved();
}
r.tick(&next, 0.0);
assert!(!left.exists(), "close {close}");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let root = root("quitafter");
let left = crashed(&edited(), &root);
let (_, r) = restart(&root);
// Quitting asked to save or discard every document.
r.finish();
assert!(!left.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
// A crashed run with this process's id whose folder the start still holds (its copy waits).
let held = root.join(std::process::id().to_string());
std::fs::create_dir_all(&held).unwrap();
let lock = std::fs::File::create(held.join("lock")).unwrap();
lock.try_lock().unwrap();
let mut r = Recovery::start(&root).expect("a folder of its own");
let s = edited();
r.write(&s, true);
assert_eq!(copies_in(&held), 0);
let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count();
assert_eq!(dirs, 1);
r.finish();
drop(lock);
let _ = std::fs::remove_dir_all(&root);
}
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
}
/// The documents a crashed run's copies under `root` open as.
fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session {
drop(r);
let mut next = Session::empty();
assert_eq!(recover(root, &mut next), 1);
next
}
#[test]
fn copies_keep_the_version_history_and_branches() {
// #185: a copy has what a save would write, the latest edits on a branch tab included.
let root = root("history");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let next = crash_and_recover(r, &root);
let ds = next.docs.first().unwrap();
assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::<Vec<_>>(), [("Explore", 2)]);
let _ = std::fs::remove_dir_all(&root);
}
/// The one copy under `root` (the run's folder), read back.
fn copy(root: &std::path::Path) -> figcraft_format::Loaded {
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap();
figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap()
}
#[test]
fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() {
let root = root("history-only");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
for d in &mut s.docs {
d.mark_saved();
}
let mut r = Recovery::start(&root).unwrap();
// An edit on the branch tab only: the main file has no changes of its own yet.
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [2]);
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [3]);
// A version renamed on the main file leaves its document as it was.
s.execute("doc.switch", &json!({"index": 0})).unwrap();
r.write(&s, true);
let id = s.docs[0].versions[0].id.clone();
s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1 (renamed)"]);
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle<bool> {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
std::fs::write(path, b"stale").is_ok()
})
}
#[test]
fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() {
let root = root("slow-cleanup");
let mut s = edited();
let mut r = Recovery::start(&root).unwrap();
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 1, slow_write(copy.clone()));
s.ds_mut().unwrap().mark_saved();
r.write(&s, false);
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(!copy.exists(), "a saved document's copy stays removed");
// Closed: the same.
let s = edited();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 1, slow_write(copy.clone()));
r.write(&Session::empty(), false);
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(!copy.exists(), "a closed document's copy stays removed");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() {
let root = root("slow-emergency");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 0, slow_write(copy.clone()));
r.write(&s, true);
assert!(std::fs::read(&copy).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write");
let _ = std::fs::remove_dir_all(&root);
}
/// The files named `*.figcraft` in the run folder under `root`.
fn copy_count(root: &std::path::Path) -> usize {
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()
}
/// A folder at the path of `s`'s first document's copy in the run folder under `root`.
fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf {
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid));
std::fs::create_dir(&blocker).unwrap();
blocker
}
#[test]
fn a_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
// A folder where the copy goes makes its write fail on every platform (renaming the run folder
// away fails on Windows, where the run's lock file keeps it open).
let blocker = blocked_copy(&root, &s);
r.write(&s, true);
std::fs::remove_dir(&blocker).unwrap();
// Nothing was edited since: the same revision still has to be copied.
r.write(&s, true);
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry-bg");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
let blocker = blocked_copy(&root, &s);
r.write(&s, false);
// Let the failing write end before the way is clear again.
std::thread::sleep(std::time::Duration::from_millis(300));
std::fs::remove_dir(&blocker).unwrap();
for _ in 0..50 {
r.write(&s, false);
if copy_count(&root) == 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
+138 -23
View File
@@ -1,15 +1,18 @@
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
use std::path::PathBuf;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
use serde_json::{Value, json};
pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
/// 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>,
@@ -31,10 +34,13 @@ pub fn config_dir() -> Option<PathBuf> {
impl Desktop {
pub fn load(recovery: crate::recovery::Shared) -> Self {
let state: Value = config_dir()
.and_then(|d| std::fs::read_to_string(d.join("state.json")).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(json!({}));
Self::load_from(config_dir(), recovery)
}
/// With the preferences kept in `dir`.
pub fn load_from(dir: Option<PathBuf>, recovery: crate::recovery::Shared) -> Self {
let state: Value =
dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
// Recent files lived next to the preferences before they moved into them.
if p.recent_files.is_empty()
@@ -48,12 +54,16 @@ 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(), prefs, reduce_motion, language: system_language(), recovery }
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
}
}
/// The system's preferred interface language as a tag ("es-ES", "es_ES.UTF-8"): Windows' language
/// list, macOS's AppleLanguages, the locale variables elsewhere.
/// The system's preferred interface languages, most preferred first, comma-separated
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
/// locale variables elsewhere. The UI shows the first language in the list that has a catalog.
/// Windows keeps only its first preferred language (and English when it has no catalog): that
/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish
/// system) must not decide the interface language.
fn system_language() -> Option<String> {
#[cfg(target_os = "windows")]
{
@@ -67,11 +77,20 @@ fn system_language() -> Option<String> {
#[cfg(target_os = "macos")]
{
let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().ok()?;
String::from_utf8_lossy(&out.stdout).split(['(', ',', ')']).map(|s| s.trim().trim_matches('"').to_string()).find(|s| !s.is_empty())
let list: Vec<String> = String::from_utf8_lossy(&out.stdout)
.split(['(', ',', ')'])
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect();
Some(list.join(","))
}
// gettext's `LANGUAGE` priority list ("fr:de"), then the locale ("fr_FR.UTF-8").
#[cfg(all(unix, not(target_os = "macos")))]
{
["LC_ALL", "LC_MESSAGES", "LANG"].iter().filter_map(|k| std::env::var(k).ok()).find(|v| !v.is_empty() && v != "C" && v != "POSIX")
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty() && v != "C" && v != "POSIX");
let locale = ["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().find_map(var);
let list: Vec<String> = var("LANGUAGE").map(|l| l.replace(':', ",")).into_iter().chain(locale).collect();
(!list.is_empty()).then(|| list.join(","))
}
#[cfg(not(any(windows, unix)))]
{
@@ -117,25 +136,102 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// 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")]
let mut command = {
let mut c = std::process::Command::new("open");
c.arg("-R").arg(path);
c
};
#[cfg(windows)]
let mut command = {
let mut arg = std::ffi::OsString::from("/select,");
arg.push(path);
let mut c = std::process::Command::new("explorer");
c.arg(arg);
c
};
#[cfg(not(any(target_os = "macos", windows)))]
let mut command = {
let mut c = std::process::Command::new("xdg-open");
c.arg(path.parent().unwrap_or(path));
c
};
// The file manager keeps running; reap it off this thread so it leaves no zombie.
let mut child = command.spawn()?;
let _ = std::thread::Builder::new().name("figcraft-reveal".into()).spawn(move || child.wait());
Ok(())
}
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
pub fn save_state(app: &FigApp) {
let Some(dir) = config_dir() else { return };
let _ = std::fs::create_dir_all(&dir);
let v = json!({"prefs": app.session.prefs});
let tmp = dir.join("state.json.tmp");
if std::fs::write(&tmp, v.to_string()).is_ok() {
let _ = std::fs::rename(&tmp, dir.join("state.json"));
if let Some(dir) = config_dir() {
write_prefs(&dir, &app.session.prefs);
}
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
let tmp = dir.join("state.json.tmp");
let written = std::fs::create_dir_all(dir)
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
if let Err(e) = written {
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
}
}
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
/// file from a crash is narrowed too: the mode given at open only applies to new files).
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o.open(path)?;
#[cfg(unix)]
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
f.write_all(bytes)
}
thread_local! {
/// While the main window draws: a dialog parented to it, so a dialog opened from it stays in
/// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent.
static PARENT: RefCell<Option<rfd::AsyncFileDialog>> = const { RefCell::new(None) };
}
/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window.
pub fn parented<R>(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R {
PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame))));
let r = f();
PARENT.with(|p| p.replace(None));
r
}
/// A dialog with `title`, parented to the main window while it draws.
fn dialog(title: &str) -> rfd::AsyncFileDialog {
PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title)
}
/// The dialog opens now; its future gives the path picked.
fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> DialogFuture {
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file()
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
}
fn pick_folder(&mut self, title: &str) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).pick_folder()
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
@@ -162,14 +258,29 @@ impl Services for Desktop {
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
fn reveal_path(&mut self, path: &Path) {
if let Err(e) = reveal(path) {
log::warn!("show in folder: {}: {e}", path.display());
}
}
fn save_prefs(&mut self, prefs: &Prefs) {
if let Some(dir) = &self.dir {
write_prefs(dir, prefs);
}
}
fn emergency_save(&mut self, session: &Session) {
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
r.write(session, true);
// Earlier runs' copies now covered go, so the next start doesn't open them twice.
r.settle(session);
}
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
fn system_language(&self) -> Option<String> {
self.language.clone()
}
@@ -177,7 +288,11 @@ impl Services for Desktop {
/// The window icon from the bundled app icon (if present).
pub fn window_icon() -> Option<egui::IconData> {
// macOS wants Apple's padded grid; the other platforms the rounded tile edge to edge.
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");
#[cfg(not(target_os = "macos"))]
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png");
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
+84
View File
@@ -0,0 +1,84 @@
//! The preferences are kept as plugins change, not only when the app quits: a plugin imported
//! (then removed) is still there (gone) when the app is killed and starts again.
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use figcraft_ui_egui::FigApp;
use serde_json::json;
use crate::services::Desktop;
const ID: &str = "org.example.tiny";
fn root(name: &str) -> std::path::PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-prefs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
std::fs::create_dir_all(&r).unwrap();
r
}
/// A plugin folder (manifest and module) in `root`.
fn plugin(root: &std::path::Path) -> std::path::PathBuf {
let dir = root.join("tiny");
std::fs::create_dir_all(&dir).unwrap();
let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}});
std::fs::write(dir.join("figcraft-plugin.json"), manifest.to_string()).unwrap();
let wasm =
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
std::fs::write(dir.join("tiny.wasm"), wasm).unwrap();
dir
}
/// The app as it starts with its preferences in `dir`.
fn start(dir: &std::path::Path) -> FigApp {
let svc = Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)));
let mut s = Session::new();
if let Some(prefs) = svc.prefs.clone() {
s.prefs = prefs;
}
FigApp::new(s, Box::new(svc))
}
/// Draw two frames (the first only sets the fonts up).
fn frames(fig: &mut FigApp, ctx: &egui::Context) {
for _ in 0..2 {
ctx.run_ui(egui::RawInput::default(), |ui| fig.ui(ui)).textures_delta.clear();
}
}
#[test]
fn plugins_are_kept_when_the_app_is_killed() {
let dir = root("killed");
let folder = plugin(&dir);
let ctx = egui::Context::default();
let mut fig = start(&dir);
assert!(fig.run("plugin.import", json!({"path": folder.to_string_lossy()})).is_some());
frames(&mut fig, &ctx);
// Killed: no quit, so nothing saves on the way out.
drop(fig);
let mut fig = start(&dir);
assert_eq!(fig.session.prefs.plugins.iter().map(|p| p.manifest.id.as_str()).collect::<Vec<_>>(), [ID], "the plugin is still installed");
assert!(fig.run("plugin.remove", json!({"id": ID})).is_some());
frames(&mut fig, &ctx);
drop(fig);
assert!(start(&dir).session.prefs.plugins.is_empty(), "the removal was kept too");
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn preferences_are_readable_by_their_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = root("private");
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
// An older world-readable file and a leftover temporary one from a crash.
for name in ["state.json", "state.json.tmp"] {
std::fs::write(dir.join(name), "{}").unwrap();
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
}
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
+27
View File
@@ -0,0 +1,27 @@
//! When the window can't open.
//!
//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable
//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees
//! and the app quit without a word (#261). Now it is logged and shown in a message box.
/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would
/// keep the caller waiting, and it reads standard error), show it in a message box.
pub fn failed(error: &eframe::Error, quiet: bool) {
// In the interface language: the text and its catalog entries are the UI crate's.
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some());
// Standard error even when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{text}");
} else {
eprintln!("figcraft: {text}");
}
if quiet {
return;
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("FigCraft")
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
+35
View File
@@ -0,0 +1,35 @@
//! The message shown when the window can't open: it carries the error, a next step and where to
//! report it, instead of the app quitting silently (#261).
use figcraft_ui_egui::dialogs::window_failed as message;
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
let text = message(ERROR, false);
assert!(text.contains(ERROR), "the error itself: {text}");
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
}
#[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("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains("unsetting"), "{text}");
}
#[cfg(windows)]
#[test]
fn on_windows_it_shows_how_to_start_with_opengl() {
let text = message(ERROR, false);
assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}");
}
#[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}");
}
+12
View File
@@ -0,0 +1,12 @@
FigCraft app icon (figcraft.svg, figcraft-small.svg and every file derived from them in this
directory: figcraft-<size>.png, figcraft.ico, hicolor/).
Original artwork made for this project by the project owner: an engraving-style drawing, then
vectorised. No third-party material. Licensed like the rest of FigCraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (https://opensource.org/licenses/MIT)
at your option.
Regenerate the derived files with `cargo xtask icon`.
+41
View File
@@ -0,0 +1,41 @@
# FigCraft app icon
A **chameleon**: an engraving portrait in profile with a curled tail, in the Crafting Apps'
owl-template framing (full-bleed field, head and body filling the tile, body running off the edges).
## Palette
Exactly three colours:
| Colour | Hex | Used for |
|---|---|---|
| Ink | `#0b0b0c` | line work and contours |
| Paper | `#efe9dc` | the figure |
| FigCraft green (app colour) | `#02da96` | the full-bleed field |
## Geometry
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
renders pad it onto Apple's 824/1024 icon grid; Windows and Linux use the tile edge to edge, with
its rounded corners, like the other Crafting Apps.
## Provenance
The owner's original engraving-style drawing (1500 px, keyed to the palette), supplied for
FigCraft and vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source
image is not kept in this repo. License: see `LICENSE.txt`.
## Files
- `figcraft.svg`: canonical master (traced at 1500 px, the source's size).
- `figcraft-small.svg`: lighter trace (traced at 1024 px); also the hicolor scalable icon.
- `figcraft-<size>.png` (16–1024): renders on the macOS grid; `packaging/macos/package.sh` builds
the `.icns` from them.
- `figcraft.ico`: Windows icon, 16–256 px, embedded in the `.exe` by `apps/figcraft/build.rs`.
- `hicolor/<size>/apps/ai.storyteller.figcraft.png` (16–512) and `hicolor/scalable/...svg`:
Linux icon theme; the 256 px one is also the runtime window icon off macOS.
## Regenerate
Replace `figcraft.svg` (and `figcraft-small.svg`), then run `cargo xtask icon`. It needs `resvg`
(`cargo install resvg`).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#02da96"/><path fill="#efe9dc" fill-rule="evenodd" d="M131 505C104 505 99 505 95 504C78 501 62 495 58 490C57 489 55 487 54 486C51 482 46 475 43 468C41 465 39 462 39 461C33 451 25 429 23 416C16 373 25 338 50 313C54 309 63 303 64 303C64 303 65 302 67 300C69 299 71 298 71 298C71 298 73 298 75 297C122 275 177 307 177 357C177 380 168 398 150 410C139 417 122 421 110 418C94 415 84 409 76 396C57 364 82 324 116 331C144 337 149 373 124 386C118 389 107 386 102 381C95 374 98 358 107 357C112 357 114 360 111 364C108 368 110 372 115 372C122 372 125 359 118 354C110 347 97 353 94 365C90 383 110 397 128 388C159 372 145 326 109 326C72 326 52 368 68 413C74 430 90 454 101 464C102 464 105 466 106 468C114 476 130 486 145 493C145 493 147 494 148 495C150 496 154 497 162 500C164 501 176 504 177 504C177 504 177 505 177 505C177 506 174 506 131 505ZM256 505C253 503 249 472 248 446C248 434 248 428 247 422C244 404 246 398 256 386C265 376 271 374 284 375C297 376 307 378 311 379C318 380 318 380 318 378C319 377 317 372 312 363C309 355 305 346 300 330C296 319 295 317 293 317C291 316 290 315 288 312C287 310 287 309 286 310C284 311 283 310 282 308C280 305 280 305 279 306C278 308 275 306 273 302C272 300 271 300 270 303C269 307 266 305 262 299C260 296 259 295 259 299C259 304 253 302 249 295C247 291 246 292 246 297C245 303 241 301 236 292C233 288 232 289 232 295C232 301 226 298 221 288C219 284 218 284 218 289C217 295 215 296 212 293C210 291 207 285 206 282C205 279 203 281 203 286C202 292 196 288 193 279C192 276 191 275 190 279C189 282 188 284 187 284C185 284 180 276 179 270C179 266 178 266 176 270C172 278 172 278 170 276C168 274 167 271 166 266C166 263 165 262 164 264C162 267 160 270 159 270C157 270 155 264 154 258C154 254 153 254 151 258C147 264 145 263 144 254C144 248 143 248 141 251C137 256 135 254 135 245C134 241 134 241 132 243C130 246 128 244 128 240L128 238 126 238C124 238 124 237 124 232L123 228 119 224C112 218 108 214 106 210C105 208 104 206 103 205C100 200 97 193 97 189C97 187 96 185 96 183C96 180 97 174 100 171C104 166 106 163 106 161C106 157 106 155 108 153C109 152 110 150 110 150C110 148 112 144 115 141C116 140 117 138 117 137C118 136 119 134 120 133C121 132 122 130 123 128C124 127 127 123 130 121C134 116 135 116 138 108C144 97 150 88 156 84C160 81 168 76 169 76C169 76 170 76 171 75C175 73 185 73 192 74C193 75 196 75 198 76C200 76 204 77 206 78L210 78 215 76C218 75 222 74 225 74C228 73 229 72 230 71C231 71 232 70 233 70C233 70 235 69 235 68C236 67 238 65 240 64C241 63 244 61 245 60C246 59 248 57 249 57C250 57 252 55 254 54C257 52 260 50 262 50C262 50 264 49 266 48C268 46 270 45 272 45C274 45 276 44 278 42C281 41 283 40 286 39C289 39 290 38 297 36C298 35 301 35 302 35C304 35 306 34 308 33C312 32 313 32 320 31C325 31 329 31 330 31C332 30 357 31 359 31C359 31 362 32 364 32C381 34 389 47 384 61C380 71 378 80 378 92L377 101 379 102C382 103 394 102 399 100C400 99 403 96 406 94C412 88 414 87 415 92C415 95 416 95 419 91C427 82 429 82 430 89C430 93 430 93 434 89C442 82 449 79 448 84C447 92 447 93 450 90C453 86 461 82 465 82C469 81 469 82 468 87C466 95 466 95 470 92C480 85 490 85 487 91C485 96 486 97 490 94C494 92 499 91 502 92C503 93 503 93 502 97C501 100 501 101 503 100C504 100 504 100 505 100C506 101 507 397 505 407C504 420 499 438 496 444C496 444 494 447 493 450C484 468 466 485 446 495C439 498 436 499 428 502C417 505 407 506 381 506C360 506 358 506 360 504C361 504 361 503 362 502C363 495 370 486 378 482C382 479 382 479 376 477C374 476 370 475 368 474C365 473 362 472 362 472C361 473 356 492 353 500C353 503 352 505 352 506C351 506 257 506 256 505ZM309 496C311 490 311 487 312 470C312 451 313 453 306 449C300 446 300 446 302 455C305 466 306 474 306 489C306 502 307 503 309 496ZM390 399C390 399 388 396 386 393C384 390 381 385 379 381C374 374 366 364 366 365C365 366 367 374 368 378C369 380 370 385 371 387C372 394 371 393 381 397C389 400 391 400 390 399Z"/><path fill="#0b0b0c" fill-rule="evenodd" d="M123 504C121 502 120 502 120 504C120 505 119 506 119 506C118 506 118 505 119 503C120 502 118 500 117 500C117 500 116 500 116 500C116 499 117 499 119 500C120 501 120 501 122 498C123 497 123 496 123 496C124 496 124 495 124 495C124 494 126 491 128 490C129 489 129 489 129 490C128 491 131 495 132 493C134 492 134 492 133 494C132 494 132 495 134 496C135 497 134 497 133 496C132 495 131 495 130 498C130 498 129 499 129 498C129 498 129 497 130 496L131 495 129 493L127 492 126 493C124 495 124 496 126 496C127 497 128 497 128 498C128 498 128 499 127 498C124 497 124 497 122 499L121 501 123 502C125 504 126 504 127 502C127 500 128 500 128 500C129 500 127 505 126 506C125 506 124 505 123 504ZM144 505C145 503 145 503 143 502C141 501 139 503 140 505C140 506 138 506 138 505C138 505 137 504 136 504C133 502 134 502 137 503C138 504 141 502 141 500C141 499 143 497 144 497C144 49Line truncated

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 103 KiB

+1 -1
View File
@@ -1 +1 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"><g><path transform="matrix(1 0 0 1 0 0)" d="M179.2 0L332.8 0C395.526 0 426.889 0 450.847 12.207C471.921 22.945 489.055 40.079 499.793 61.153C512 85.111 512 116.474 512 179.2L512 332.8C512 395.526 512 426.889 499.793 450.847C489.055 471.921 471.921 489.055 450.847 499.793C426.889 512 395.526 512 332.8 512L179.2 512C116.474 512 85.111 512 61.153 499.793C40.079 489.055 22.945 471.921 12.207 450.847C0 426.889 0 395.526 0 332.8L0 179.2C0 116.474 0 85.111 12.207 61.153C22.945 40.079 40.079 22.945 61.153 12.207C85.111 0 116.474 0 179.2 0Z" fill="#0EA5C6"/></g><g><path transform="matrix(1 0 0 1 150 96)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 150 96)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 122 150)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 122 150)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 94 204)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 94 204)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.5Line truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#02da96"/><path fill="#efe9dc" fill-rule="evenodd" d="M105.6 505.5C99.1 505 89.7 503.4 83.5 501.6C80 500.6 73.9 498.6 73.6 498.4C73.4 498.2 72.4 497.8 71.4 497.5C68.8 496.7 61.6 493.2 59.8 491.9C59 491.3 57.8 490.1 57.2 489.2C56.6 488.3 55.4 486.8 54.6 485.9C52.1 483.2 47.4 476.4 44.9 471.9C43.6 469.7 41.5 465.9 40.2 463.6C19 426.3 14.4 381.3 28.1 346.2C33.1 333.6 39.6 323.6 49.1 314C54.4 308.7 57.7 306.1 64 302.4C84.1 290.3 101.6 287.3 124.1 292.1C146.3 296.8 164.7 312.7 173.3 334.6C176.6 343 177.5 348.8 177.1 359.9C176.7 370.9 175.4 376.8 171.4 385.2C163.2 402.3 149.5 413.1 129.5 418.2C124.7 419.4 113.8 419.3 107.9 418C91.3 414.3 81.2 406.5 73.9 391.7C69.7 383.2 68.4 374.5 70 365.5C72.3 352 79.3 341.5 90.5 334.7C98.3 330.1 111 329 120.2 332.3C128 335.1 135.5 342.2 138 349C141.6 359.2 140.7 368.8 135.2 376.9C133.1 379.9 132.8 380.2 128.7 383.1C124 386.2 122.2 386.8 117.8 386.8C107 386.9 98.9 381 98.1 372.7C97.6 367.4 99 362.1 101.6 359.7C104.3 357 107.5 356.1 110.2 357.4C113 358.6 113.3 361.4 111.1 364.3C108.4 367.7 109.5 370.6 113.8 371.7C118 372.9 121.9 368.5 122 362.8C122.1 356.6 117.4 351.6 110.6 350.7C106.1 350.1 99.5 353.8 96.4 358.7C93.8 362.9 92.8 370.9 94.3 375.5C96.8 382.8 102 387.6 110.1 390C113.4 391 120.6 390.9 123.7 389.8C137.4 385.2 146.2 371.9 144.7 358.2C143.7 348.3 139.2 339.3 132.8 334.1C118 322.1 96.3 323 80.9 336.3C60.3 354.2 56.7 387.8 71.7 421.6C74.8 428.5 76.8 432.3 79.2 435.9C79.9 436.9 81.5 439.3 82.7 441.2C93.2 457.3 109.3 472.5 128.1 484C131.3 486 141.8 491.4 143.7 492C144.5 492.3 146 493.1 146.9 493.7C149.7 495.6 161.4 500.2 167.3 501.8C168.9 502.2 171.2 502.8 172.4 503.3C173.6 503.7 175.7 504.3 177.1 504.6C178.4 505 179.5 505.4 179.5 505.6C179.5 505.9 111.2 505.8 105.6 505.5ZM255.2 505.6C254.8 504.9 252.8 493.7 251.6 485.4C249.4 470.1 248.7 460.4 248.3 440.8C248.1 431.2 247.6 425.4 246.1 416.3C244 403.7 246.6 396.6 257.8 383.9C263.5 377.5 269.7 374.4 277.2 374.4C279.1 374.4 281.3 374.6 282 374.8C282.8 375 285.4 375.3 287.7 375.4C296.8 376.1 309.3 377.9 314.4 379.2C318.5 380.3 318.9 380.2 318.6 378.5C318.3 376.5 317.6 374.5 316.2 371.2C315 368.7 314.3 367.2 310.4 358.8C308.1 354.1 305 345.4 300.9 332.8C298.9 326.7 296.7 320.6 296 319.1C294.7 316.6 294.7 316.6 293.3 316.6C291.2 316.6 289.8 315.3 288.4 312.2C287.2 309.5 286.8 309.2 285.5 310.1C284 311.3 282.4 310.2 281.2 307.3C280.2 304.6 280.1 304.5 278.9 305.8C277.7 307.1 277.8 307 276.1 306.2C274.8 305.5 274 304.5 272.3 301.4C271.6 300.1 270.9 300.4 270.5 302.3C270.1 304.4 269.8 304.8 268.3 304.8C266.7 304.8 264.1 302.2 261.8 298.4C259.9 295.1 259.1 295.2 259.1 298.9C259.1 302.1 257.9 303 255.5 301.6C253.1 300.2 250.7 297.8 249 295.1C246.6 291.5 245.8 291.8 245.8 296.2C245.8 303.1 241.1 301.2 235.7 292.2C234.7 290.6 233.7 289.3 233.5 289.2C232.9 289 232.5 290.6 232.2 293.8C231.9 298.3 230.6 299.3 227.8 297.4C225.7 295.8 223.8 293.3 221.5 289C220.4 286.8 219.2 285 219 285C218.5 285 217.8 287.6 217.4 290.9C216.6 298.5 210.1 293.7 206.3 282.7C205.8 281.2 205.2 279.9 204.9 279.9C204.4 279.9 203.1 283.1 202.9 285.2C202.3 290.5 200.8 291 197.3 286.9C195.4 284.7 193.6 281.3 192.7 277.9C191.9 275.3 191.2 275.2 190.5 277.7C189.7 280.8 188.3 284 187.9 284.2C185.3 285.2 180 276.8 179.2 270.3C178.6 265.7 177.5 266.1 175.3 271.8C173.9 275.3 172.9 277.1 172.2 277.4C171.2 277.7 169.3 276.1 168.3 274.1C167.2 271.9 165.6 265.2 165.5 262.7C165.5 261.7 164.2 263.1 162.7 265.5C161.7 267.1 160.5 268.7 160 269.1L159.1 269.8 158.2 268.9C155.9 266.9 154.6 263.2 154.1 257.8C154 256.2 153.7 254.9 153.6 254.8C153.2 254.6 152.3 255.7 150.4 258.7C148.3 261.8 147.8 262.2 146.5 261.5C145 260.6 144.4 259 144 253.9C143.5 247.3 143.6 247.3 140.8 251.1L138.8 253.7 137.4 253.4C136.6 253.3 135.9 253.1 135.8 253C135.1 252.3 134.7 249.8 134.6 246C134.5 240.9 134.3 240.4 132.9 242.2C130.3 245.8 128.5 245.1 127.9 240.4C127.4 236.7 127.5 236.8 126.3 237.5C124.6 238.5 123.9 237.5 123.5 233.1C123.1 228.5 122.7 227.9 119.9 225.4C111.9 218.5 108.2 214.4 105.7 209.6C104.6 207.4 103 204.6 102.2 203.4C101.4 202.1 100.7 201 100.7 200.9C100.7 200.8 99.8 199 98.8 196.7L96.8 192.7 96.8 189.3C96.8 187.3 96.6 185.5 96.3 185.1C95.6 183.8 95.8 180.3 96.9 177.1C98.1 173.5 98.4 173 100.4 170.5C104.4 165.3 105 164.2 105.3 161.1C105.6 157.1 106.3 155 107.5 153.8C109.1 152.3 109.8 151 110.2 148.8C110.7 146.3 112.5 143.4 115.1 140.7C116.2 139.6 117.1 138.4 117.1 138.1C117.1 137.2 119 133.7 120 132.8C120.5 132.3 121.4 131 122.1 129.9C124 126.7 126.9 123.4 130.3 120.3C133.6 117.4 134.4 116.2 136.7 111.3C137.4 109.9 138.1 108.4 138.3 108C139 106.8 139.2 106.3 140.3 104.1C142.2 99.9 148.2 91.5 152.3 87.2C155.5 83.8 158 81.9 162 79.6C163.3 78.9 165 77.8 165.8 77.3C166.7 76.6 167.8 76.1 168.8 76C169.7 75.9 170.9 75.5 171.5 75.1C174.3 73.4 185.1 72.8 190.9 74.1C192.6 74.4 195.8 75.1 198 75.6C20Line truncated

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1 +1 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"><g><path transform="matrix(1 0 0 1 0 0)" d="M179.2 0L332.8 0C395.526 0 426.889 0 450.847 12.207C471.921 22.945 489.055 40.079 499.793 61.153C512 85.111 512 116.474 512 179.2L512 332.8C512 395.526 512 426.889 499.793 450.847C489.055 471.921 471.921 489.055 450.847 499.793C426.889 512 395.526 512 332.8 512L179.2 512C116.474 512 85.111 512 61.153 499.793C40.079 489.055 22.945 471.921 12.207 450.847C0 426.889 0 395.526 0 332.8L0 179.2C0 116.474 0 85.111 12.207 61.153C22.945 40.079 40.079 22.945 61.153 12.207C85.111 0 116.474 0 179.2 0Z" fill="#0EA5C6"/></g><g><path transform="matrix(1 0 0 1 150 96)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 150 96)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 122 150)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 122 150)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 94 204)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 94 204)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.5Line truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#02da96"/><path fill="#efe9dc" fill-rule="evenodd" d="M131 505C104 505 99 505 95 504C78 501 62 495 58 490C57 489 55 487 54 486C51 482 46 475 43 468C41 465 39 462 39 461C33 451 25 429 23 416C16 373 25 338 50 313C54 309 63 303 64 303C64 303 65 302 67 300C69 299 71 298 71 298C71 298 73 298 75 297C122 275 177 307 177 357C177 380 168 398 150 410C139 417 122 421 110 418C94 415 84 409 76 396C57 364 82 324 116 331C144 337 149 373 124 386C118 389 107 386 102 381C95 374 98 358 107 357C112 357 114 360 111 364C108 368 110 372 115 372C122 372 125 359 118 354C110 347 97 353 94 365C90 383 110 397 128 388C159 372 145 326 109 326C72 326 52 368 68 413C74 430 90 454 101 464C102 464 105 466 106 468C114 476 130 486 145 493C145 493 147 494 148 495C150 496 154 497 162 500C164 501 176 504 177 504C177 504 177 505 177 505C177 506 174 506 131 505ZM256 505C253 503 249 472 248 446C248 434 248 428 247 422C244 404 246 398 256 386C265 376 271 374 284 375C297 376 307 378 311 379C318 380 318 380 318 378C319 377 317 372 312 363C309 355 305 346 300 330C296 319 295 317 293 317C291 316 290 315 288 312C287 310 287 309 286 310C284 311 283 310 282 308C280 305 280 305 279 306C278 308 275 306 273 302C272 300 271 300 270 303C269 307 266 305 262 299C260 296 259 295 259 299C259 304 253 302 249 295C247 291 246 292 246 297C245 303 241 301 236 292C233 288 232 289 232 295C232 301 226 298 221 288C219 284 218 284 218 289C217 295 215 296 212 293C210 291 207 285 206 282C205 279 203 281 203 286C202 292 196 288 193 279C192 276 191 275 190 279C189 282 188 284 187 284C185 284 180 276 179 270C179 266 178 266 176 270C172 278 172 278 170 276C168 274 167 271 166 266C166 263 165 262 164 264C162 267 160 270 159 270C157 270 155 264 154 258C154 254 153 254 151 258C147 264 145 263 144 254C144 248 143 248 141 251C137 256 135 254 135 245C134 241 134 241 132 243C130 246 128 244 128 240L128 238 126 238C124 238 124 237 124 232L123 228 119 224C112 218 108 214 106 210C105 208 104 206 103 205C100 200 97 193 97 189C97 187 96 185 96 183C96 180 97 174 100 171C104 166 106 163 106 161C106 157 106 155 108 153C109 152 110 150 110 150C110 148 112 144 115 141C116 140 117 138 117 137C118 136 119 134 120 133C121 132 122 130 123 128C124 127 127 123 130 121C134 116 135 116 138 108C144 97 150 88 156 84C160 81 168 76 169 76C169 76 170 76 171 75C175 73 185 73 192 74C193 75 196 75 198 76C200 76 204 77 206 78L210 78 215 76C218 75 222 74 225 74C228 73 229 72 230 71C231 71 232 70 233 70C233 70 235 69 235 68C236 67 238 65 240 64C241 63 244 61 245 60C246 59 248 57 249 57C250 57 252 55 254 54C257 52 260 50 262 50C262 50 264 49 266 48C268 46 270 45 272 45C274 45 276 44 278 42C281 41 283 40 286 39C289 39 290 38 297 36C298 35 301 35 302 35C304 35 306 34 308 33C312 32 313 32 320 31C325 31 329 31 330 31C332 30 357 31 359 31C359 31 362 32 364 32C381 34 389 47 384 61C380 71 378 80 378 92L377 101 379 102C382 103 394 102 399 100C400 99 403 96 406 94C412 88 414 87 415 92C415 95 416 95 419 91C427 82 429 82 430 89C430 93 430 93 434 89C442 82 449 79 448 84C447 92 447 93 450 90C453 86 461 82 465 82C469 81 469 82 468 87C466 95 466 95 470 92C480 85 490 85 487 91C485 96 486 97 490 94C494 92 499 91 502 92C503 93 503 93 502 97C501 100 501 101 503 100C504 100 504 100 505 100C506 101 507 397 505 407C504 420 499 438 496 444C496 444 494 447 493 450C484 468 466 485 446 495C439 498 436 499 428 502C417 505 407 506 381 506C360 506 358 506 360 504C361 504 361 503 362 502C363 495 370 486 378 482C382 479 382 479 376 477C374 476 370 475 368 474C365 473 362 472 362 472C361 473 356 492 353 500C353 503 352 505 352 506C351 506 257 506 256 505ZM309 496C311 490 311 487 312 470C312 451 313 453 306 449C300 446 300 446 302 455C305 466 306 474 306 489C306 502 307 503 309 496ZM390 399C390 399 388 396 386 393C384 390 381 385 379 381C374 374 366 364 366 365C365 366 367 374 368 378C369 380 370 385 371 387C372 394 371 393 381 397C389 400 391 400 390 399Z"/><path fill="#0b0b0c" fill-rule="evenodd" d="M123 504C121 502 120 502 120 504C120 505 119 506 119 506C118 506 118 505 119 503C120 502 118 500 117 500C117 500 116 500 116 500C116 499 117 499 119 500C120 501 120 501 122 498C123 497 123 496 123 496C124 496 124 495 124 495C124 494 126 491 128 490C129 489 129 489 129 490C128 491 131 495 132 493C134 492 134 492 133 494C132 494 132 495 134 496C135 497 134 497 133 496C132 495 131 495 130 498C130 498 129 499 129 498C129 498 129 497 130 496L131 495 129 493L127 492 126 493C124 495 124 496 126 496C127 497 128 497 128 498C128 498 128 499 127 498C124 497 124 497 122 499L121 501 123 502C125 504 126 504 127 502C127 500 128 500 128 500C129 500 127 505 126 506C125 506 124 505 123 504ZM144 505C145 503 145 503 143 502C141 501 139 503 140 505C140 506 138 506 138 505C138 505 137 504 136 504C133 502 134 502 137 503C138 504 141 502 141 500C141 499 143 497 144 497C144 49Line truncated

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 453 KiB

+28
View File
@@ -16,3 +16,31 @@
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.VanTan666]
display_name = "VanTan"
[people.tylerjenningsw]
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.yyamandevrim]
real_name = "Yaman Devrim"
sync_github_name = true
[people.justinjohn0306]
real_name = "Justin John"
[people.Losminek]
[people.RevDonz]
[people.Nael-Nathanael]
sync_github_name = true
[people.volkovskey]
real_name = "Andrii Volkov"
+53
View File
@@ -0,0 +1,53 @@
//! Addresses removed from a file are kept only while its session lasts, so a long-running server
//! doesn't collect them for every file it ever had open.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use figcraft_collab::{Limits, Server};
use figcraft_doc::Document;
use super::{Admission, Backend, Closing, Hub, InFlight, Room};
/// A backend that closes a file as soon as everyone left it.
struct Closes;
impl Backend for Closes {
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
Err("no".into())
}
fn open(&mut self, _file: &str) -> Result<Server, String> {
Err("no".into())
}
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
true
}
fn closing(&self) -> Closing {
Closing::Restarting
}
}
#[test]
fn a_closed_file_forgets_whom_it_barred() {
let mut doc = Document::new("File");
doc.repair();
let mut hub = Hub {
in_flight: InFlight(Default::default()),
backend: Closes,
rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]),
conns: HashMap::new(),
local: None,
outbox: Vec::new(),
barred: HashMap::new(),
};
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7));
hub.barred.entry("f".into()).or_default().insert(ip);
hub.barred.entry("other".into()).or_default().insert(ip);
hub.close_if_empty("f");
assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed");
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
assert!(hub.barred.contains_key("other"), "another file's stay");
}
+318
View File
@@ -0,0 +1,318 @@
//! Join codes over the network: a file shared on the local network joined by its code (two
//! engine sessions over loopback), what the browser's driver does with an online code, a server
//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out
//! and resolving them is tested in its own crate, with the same link.)
use std::net::{Ipv4Addr, TcpListener};
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::code::{self, Code, Local};
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, ServerMsg};
use figcraft_engine::Session;
use figcraft_engine::live::{Event, Status};
use serde_json::json;
use tungstenite::{Bytes, Message};
use crate::driver::{Driver, Socket};
use crate::guest::GuestConfig;
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
use crate::loopback_tests::{fast_limits, quick};
use crate::{NetLink, manage};
fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump the sessions (as their windows would every frame) until `f` holds.
fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
let start = Instant::now();
loop {
for s in all.iter_mut() {
s.live_poll();
s.live_sync();
}
if f(all) {
return;
}
let states: Vec<String> = all.iter().map(|s| s.live_summary().to_string()).collect();
assert!(start.elapsed() < Duration::from_secs(15), "timed out waiting for {what}: {states:#?}");
std::thread::sleep(Duration::from_millis(5));
}
}
fn live(s: &Session) -> bool {
s.live().status == Status::Live && s.live().syncing()
}
fn layers(s: &Session) -> usize {
s.doc().map_or(0, |d| d.node_count())
}
#[test]
fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let (mut host, mut guest) = (session("Hana"), session("Ada"));
// This machine only, so the test needs no network; the code says where and the secret.
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
assert_eq!((&shared["status"], &shared["share"]["via"], &shared["share"]["access"]), (&json!("live"), &json!("local"), &json!("edit")));
let shown = shared["code"].as_str().unwrap().to_string();
assert_eq!(shown.len(), 23, "twenty symbols in fives: {shown}");
let Code::Local(local) = code::parse(&shown).unwrap() else { panic!("not a local code") };
assert_eq!(local.ip, Ipv4Addr::LOCALHOST);
assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port));
assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session");
assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network");
// Typed the way people type: lower case, spaces for the dashes.
let typed = shown.to_lowercase().replace('-', " ");
assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting");
pump(&mut [&mut host, &mut guest], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(guest.live_summary()["access"], "edit");
// Edits go both ways.
let before = layers(&host);
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
pump(&mut [&mut host, &mut guest], "the host's edit", |s| layers(s[1]) == before + 1);
guest.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap();
pump(&mut [&mut host, &mut guest], "the guest's edit", |s| layers(s[0]) == before + 2);
// The link joins the same session (an older app, or a paste).
let mut third = session("Bo");
third.execute("collab.join", &json!({"link": shared["link"]})).unwrap();
pump(&mut [&mut host, &mut guest, &mut third], "the link's join", |s| live(s[2]) && layers(s[2]) == before + 2);
// Stopping ends it: the code no longer joins.
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
pump(&mut [&mut host, &mut guest, &mut third], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
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);
}
#[test]
fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
let (mut host, mut guest) = (session("Hana"), session("Ada"));
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
// A code for the same address with another secret: well formed, refused by the host.
let guess = Local::new(local.ip, local.port, local.secret ^ 1).code();
guest.execute("collab.join", &json!({"code": guess})).unwrap();
pump(&mut [&mut host, &mut guest], "the refusal", |s| matches!(s[1].live().status, Status::Failed { .. }));
assert!(guest.live().status.label().contains("wrong session token"), "{:?}", guest.live().status);
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' };
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.
let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string();
assert!(e.contains("share it online"), "{e}");
}
#[test]
fn links_can_name_a_join_code() {
let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap();
assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", ""));
assert_eq!(found.as_deref(), Some("K7M2QX9TD"));
let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap();
assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD")));
// A link with a file and a token is read as before.
let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None));
for bad in [
"figcraft://join?server=wss://share.example/&code=",
"figcraft://join?server=wss://share.example/&code=K7M",
"figcraft://join?code=K7M2QX9TD",
] {
assert!(Invite::parse_join(bad).is_err(), "{bad}");
}
// Where a file's link is needed (team libraries), a code isn't one.
let e = Invite::parse("figcraft://join?server=wss://share.example/&code=K7M2QX9TD").unwrap_err();
assert!(e.contains("join code") && !e.contains("K7M"), "{e}");
}
/// A server from before join codes: whatever it is asked first, it can't read it (what its
/// decoder says about a message it doesn't know).
fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
let t = std::thread::spawn(move || {
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let _ = ws.read();
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
let _ = ws.close(None);
while ws.read().is_ok() {}
}
});
(at, t)
}
/// A server from before join codes that manages a file's links as it always did: two connections,
/// the first makes links, the second (asked for codes) can't read the request.
fn old_server_with_links() -> (Endpoint, std::thread::JoinHandle<()>) {
use figcraft_collab::message::{Admin, AdminReply, LinkInfo, NewLink};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
let t = std::thread::spawn(move || {
let mut made = 0;
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let say = |ws: &mut tungstenite::WebSocket<std::net::TcpStream>, m: ServerMsg| {
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&m).unwrap())));
};
while let Ok(Message::Binary(b)) = ws.read() {
match message::decode::<ClientMsg>(&b, usize::MAX) {
Ok(ClientMsg::OpenHosting(_)) => say(&mut ws, ServerMsg::Hosting(Box::default())),
Ok(ClientMsg::Admin(Admin::CreateLink { file, access, label })) => {
made += 1;
let link = LinkInfo { id: format!("link{made}"), file, access, label, created: 0 };
say(&mut ws, ServerMsg::Admin(Box::new(AdminReply::Link(NewLink { link, token: format!("TokenTokenTokenToken0{made}") }))));
}
_ => {
say(&mut ws, ServerMsg::Refused { reason: "that message couldn't be read".into() });
break;
}
}
}
let _ = ws.close(None);
while ws.read().is_ok() {}
}
});
(at, t)
}
#[test]
fn a_server_older_than_join_codes_says_so_and_the_links_still_work() {
let (server, thread) = old_server();
let limits = fast_limits();
// Asking it about a code: the join fails with what to do instead.
let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err();
assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}");
let mut guest = session("Ada");
guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap();
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. }));
let why = guest.live().status.label();
assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}");
thread.join().unwrap();
// Sharing a file there: it makes the links (it always could) but no codes, without an error:
// the app shows the links.
let (server, thread) = old_server_with_links();
let shared =
manage::share(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &[], true, &limits, Duration::from_secs(5)).unwrap().unwrap();
assert!(shared.edit.code.is_none() && shared.view.as_ref().is_some_and(|v| v.code.is_none()));
assert!(shared.edit.link.contains("&file=0123456789abcdef&token=TokenTokenTokenToken01") && shared.edit.id == "link1");
thread.join().unwrap();
}
#[test]
fn a_server_that_cant_be_reached_says_so() {
// A port nothing listens on.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let mut guest = session("Ada");
guest.execute("pref.publishServer", &json!({"url": format!("ws://127.0.0.1:{port}/")})).unwrap();
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
pump(&mut [&mut guest], "the failure", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(guest.live().status.label().starts_with("Couldn't reach the server"), "{:?}", guest.live().status);
}
#[test]
fn an_app_hosted_session_knows_no_online_codes() {
let mut host = session("Hana");
let shared = host.execute("collab.host", &json!({"port": 0})).unwrap();
let server = Endpoint::plain(shared["address"].as_str().unwrap());
let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("hosted by an app"), "{e}");
// Junk in place of a code is refused the same way, and the session goes on.
for junk in ["", "💥", &"Z".repeat(4000)] {
assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err());
}
assert_eq!(host.live_summary()["status"], "live");
}
/// A socket that records what the driver does (the browser's, scripted).
#[derive(Default)]
struct Script {
opened: Vec<String>,
sent: Vec<ClientMsg>,
closes: usize,
}
impl Socket for Script {
fn open(&mut self, url: &str) -> Result<(), String> {
self.opened.push(url.to_string());
Ok(())
}
fn send(&mut self, bytes: &[u8]) -> bool {
self.sent.push(message::decode(bytes, usize::MAX).unwrap());
true
}
fn close(&mut self) {
self.closes += 1;
}
}
/// The browser's driver joining with a code at a server.
fn driver(t0: web_time::Instant) -> (Driver<Script>, Inbox) {
let (invite, found) = Invite::parse_join("figcraft://join?server=ws://127.0.0.1:47900/&code=K7M2QX9TD").unwrap();
let inbox = Inbox::new(Arc::new(|| {}));
let cfg = GuestConfig { code: found, ..GuestConfig::new(invite, "Ada".into()) };
(Driver::start(Script::default(), cfg, inbox.clone(), t0), inbox)
}
fn failure(inbox: &Inbox) -> String {
inbox.drain().into_iter().find_map(|e| if let Event::Status(Status::Failed { reason }) = e { Some(reason) } else { None }).unwrap_or_default()
}
#[test]
fn the_browser_asks_about_the_code_then_joins_the_file() {
let t0 = web_time::Instant::now();
let (mut d, inbox) = driver(t0);
d.opened(t0);
let [ClientMsg::Resolve(ask)] = d.sock.sent.as_slice() else { panic!("asked {:?}", d.sock.sent) };
assert_eq!((ask.protocol, ask.code.as_str()), (PROTOCOL, "K7M2QX9TD"));
// The answer: a new connection to the same server, and a hello for the file.
let answer = ServerMsg::Resolved { file: "0123456789abcdef".into(), token: "AbCdEfGhIjKlMnOpQrStUv".into() };
d.message(&message::encode(&answer).unwrap(), t0);
assert_eq!((d.sock.opened.len(), d.sock.closes), (2, 1));
assert!(!d.done());
d.opened(t0);
let Some(ClientMsg::Hello(hello)) = d.sock.sent.last() else { panic!("sent {:?}", d.sock.sent) };
assert_eq!((hello.file.as_str(), hello.token.as_str(), hello.name.as_str()), ("0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", "Ada"));
assert!(failure(&inbox).is_empty());
}
#[test]
fn the_browser_shows_why_a_code_didnt_join() {
let t0 = web_time::Instant::now();
// The server's own words for a code it doesn't know.
let (mut d, inbox) = driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Refused { reason: "No session with that code".into() }).unwrap(), t0);
assert!(d.done());
assert_eq!(failure(&inbox), "No session with that code");
// An older server.
let (mut d, inbox) = driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Refused { reason: "that message couldn't be read".into() }).unwrap(), t0);
assert!(failure(&inbox).contains("doesn't know join codes yet"));
// Nobody answers: no retries while still asking.
let (mut d, inbox) = driver(t0);
d.tick(t0 + Duration::from_secs(30));
assert!(d.done());
assert!(failure(&inbox).starts_with("Couldn't reach the server"));
// An answer that isn't a file.
let (mut d, inbox) = driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Resolved { file: "../etc".into(), token: "short".into() }).unwrap(), t0);
assert!(d.done());
assert!(failure(&inbox).contains("isn't a file"));
// Leaving while asking is just leaving.
let (mut d, inbox) = driver(t0);
d.leave(t0);
assert!(d.done() && failure(&inbox).is_empty());
}
+23 -4
View File
@@ -5,14 +5,18 @@
//! 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`].
//!
//! 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
//! code (or can't be reached) ends the join with the reason.
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Presence, Resolve, ServerMsg};
use figcraft_engine::live::{Event, Status};
// `std::time::Instant` on native builds; the browser's clock in the web build.
use web_time::Instant;
use crate::inbox::Inbox;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
@@ -93,12 +97,15 @@ impl<S: Socket> Driver<S> {
}
}
/// The connection is open: say hello.
/// The connection is open: say hello (or ask about the join code first).
pub(crate) fn opened(&mut self, now: Instant) {
let Some(c) = self.conn.as_mut() else { return };
c.opened = true;
c.last_heard = now;
let hello = hello(&self.cfg, &self.resume);
let hello = match &self.cfg.code {
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) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -109,6 +116,16 @@ impl<S: Socket> Driver<S> {
let Some(c) = self.conn.as_mut() else { return };
c.last_heard = now;
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
// The join code's answer: join the file it opens, on a new connection.
Ok(ServerMsg::Resolved { file, token }) if self.cfg.code.is_some() => {
return match resolved(&mut self.cfg, file, token) {
Ok(()) => {
self.sock.close();
self.connect(now);
}
Err(o) => self.end(o, now),
};
}
// Single-threaded: the connection counts as welcomed below, before the engine polls.
Ok(m) => take(m, &self.cfg, &self.inbox, &mut c.seen, &self.resume, &|| {}),
Err(e) => Taken::End(Outcome::Refused(format!("the host sent a message this version can't read ({e})"))),
@@ -233,6 +250,8 @@ impl<S: Socket> Driver<S> {
/// The connection ended: close it, then stop or plan the reconnect.
fn end(&mut self, outcome: Outcome, now: Instant) {
// Still asking about the join code: whatever went wrong is final.
let outcome = if self.cfg.code.is_some() { unresolved(&self.cfg, outcome) } else { outcome };
if self.conn.take().is_some() {
self.sock.close();
}
+64 -20
View File
@@ -16,7 +16,7 @@ use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Presence, ServerMsg};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, Published, Status};
use tungstenite::protocol::frame::coding::CloseCode;
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@@ -34,11 +34,13 @@ use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// What [`Guest::publish`] uploads, and where.
pub struct Publish {
pub server: Endpoint,
/// The server's key.
pub key: String,
/// The server's key (`None`: publish as anyone may, on a server open to the public).
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
pub share: bool,
}
/// A joined (or joining) session. Dropping it leaves.
@@ -60,10 +62,27 @@ enum Cmd {
impl Guest {
/// Start joining (returns at once; progress arrives in `inbox`).
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
pub fn start(mut cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
let (tx, rx) = channel();
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || supervise(&cfg, &inbox, &rx, &me))?;
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || {
// A join code: the server says which file it opens, then the join goes on as with a link.
if let Some(code) = cfg.code.clone() {
inbox.push(Event::Status(Status::Connecting));
let asked =
manage::resolve(&cfg.invite.server, &code, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
Some(why) => Outcome::Refused(why.to_string()),
None => Outcome::Retry(e),
});
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
inbox.push(e);
}
return;
}
}
supervise(&cfg, &inbox, &rx, &me);
})?;
Ok(Guest { cmd: tx, thread: Some(thread) })
}
@@ -75,11 +94,25 @@ impl Guest {
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-publish".into()).spawn(move || {
inbox.push(Event::Status(Status::Connecting));
let (cfg, published) = match upload(&job, cfg) {
let (cfg, published, hosting) = match upload(&job, cfg) {
Ok(done) => done,
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
};
// Before anyone hears the file is there, so what others join with shows with it. The
// file's first links stay its owner's (this app joins with one): others get their own,
// which stopping takes away again.
let shared = job.share.then(|| {
let owner = published.owner.as_deref().unwrap_or_default();
let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout);
made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))
});
if let Some(h) = hosting {
inbox.push(Event::Hosting(h));
}
inbox.push(Event::Published(published));
if let Some(shared) = shared {
inbox.push(Event::Shared(shared));
}
// Left while uploading: the file stays on the server, nobody joins it. Anything else
// that came meanwhile (presence) waits for the session.
let mut waiting = Vec::new();
@@ -138,33 +171,44 @@ struct Conn {
sock: TcpStream,
}
/// Keep `doc` on `server` as a new file named `name`: its edit link, and where it is with its links.
/// Keep `doc` on `server` as a new file named `name`, with the server's `key` (or, without one, as
/// anyone may on a server open to the public): its edit link, where it is with its links (and its
/// owner token without a key), and what the server says about its hosting (without a key).
pub(crate) fn upload_file(
server: &Endpoint,
key: &str,
key: Option<&str>,
name: &str,
doc: &Document,
limits: &NetLimits,
timeout: Duration,
) -> Result<(Invite, Published), String> {
) -> Result<(Invite, Published, Option<HostInfo>), String> {
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
let replies = manage::request(server, key, vec![Admin::Publish { name: name.to_string(), snapshot }], limits, timeout)?;
let (file, edit, view) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
let publish = vec![Admin::Publish { name: name.to_string(), snapshot }];
let (replies, hosting) = match key {
Some(key) => (manage::request(server, key, publish, limits, timeout)?, None),
None => {
let (info, replies) = manage::hosting(server, "", "", publish, limits, timeout)?;
(replies, Some(info))
}
};
let (file, edit, view, owner, expires) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
Some(AdminReply::Failed { reason }) => return Err(reason),
_ => return Err("the server didn't say where the file is".into()),
};
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
let (edit, view) = (link(edit.token), link(view.token));
let published = Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
Ok((edit, published))
let published =
Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link(), owner, expires };
Ok((edit, published, hosting))
}
/// Upload the document; the join settings for the new file and its links.
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
let (edit, published) = upload_file(&job.server, &job.key, &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
/// Upload the document; the join settings for the new file, its links and the server's hosting.
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published, Option<HostInfo>), String> {
let (edit, published, hosting) = upload_file(&job.server, job.key.as_deref(), &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
cfg.invite = edit;
Ok((cfg, published))
Ok((cfg, published, hosting))
}
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
@@ -177,7 +221,7 @@ fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
}
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
let resume: Arc<Resuming> = Arc::default();
let resume: Arc<Resuming> = Arc::new(std::sync::Mutex::new(cfg.resume));
let mut attempt = 0u32;
let mut was_live = false;
let mut pending: Option<Presence> = None;
+13 -6
View File
@@ -11,7 +11,8 @@ use figcraft_collab::{Outgoing, Server, same_secret};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, PEOPLE};
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name};
pub use crate::hub::REMOVED;
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
use crate::inbox::Inbox;
pub use crate::join::ENDED;
use crate::net::NetLimits;
@@ -85,16 +86,16 @@ impl Host {
token: String::new(),
name,
color: PEOPLE[0].to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
client_version: figcraft_collab::message::client_version(),
resume: None,
};
let (me, outs) = server.join(hello).map_err(|r| io::Error::other(format!("{r:?}")))?;
// The host's window syncs through its own client too: its welcome carries the document.
for o in outs {
if let Outgoing::To(id, w @ ServerMsg::Welcome { .. }) = o
&& id == me
{
inbox.push(Event::Sync(w));
match o {
Outgoing::To(id, w @ ServerMsg::Welcome { .. }) if id == me => inbox.push(Event::Sync(w)),
Outgoing::To(id, ref m @ ServerMsg::Signal { .. }) if id == me => to_local(&inbox, me, m),
_ => {}
}
}
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
@@ -129,6 +130,12 @@ impl Host {
self.hosting.send(msg);
}
/// Remove client `id` from the session: they are disconnected and can't join again from
/// their address while it runs. The host can't remove themselves.
pub fn remove(&self, id: u32) {
self.hosting.remove(id);
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
+1013 -138
View File
File diff suppressed because it is too large. Load diff
+19 -1
View File
@@ -10,6 +10,10 @@
//! connects to the endpoint and sends the token in its `Hello`. A server address for managing
//! files carries the server's key the same way (`ws://host:port/?key=<key>`). Errors never echo a
//! link or an address.
//!
//! A join by code names the server to ask and the code:
//! `figcraft://join?server=<ws(s)://…>&code=<code>` ([`Invite::parse_join`]); the server says which
//! file and token the code stands for.
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -134,6 +138,15 @@ impl Invite {
/// Read an invite link (any form; surrounding spaces, quotes or angle brackets are fine).
pub fn parse(link: &str) -> Result<Invite, String> {
match Self::parse_join(link)? {
(invite, None) => Ok(invite),
(_, Some(_)) => Err("that names a join code, not a file: join with it first".into()),
}
}
/// Read what a join starts from: an invite link, or a server with a join code to ask it about
/// (then the invite names only the server, until the server says what the code opens).
pub fn parse_join(link: &str) -> Result<(Invite, Option<String>), String> {
let link = clean(link);
if link.len() > MAX_LINK {
return Err(NOT.into());
@@ -158,9 +171,14 @@ impl Invite {
return Err(NOT.into());
};
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
if let Some(code) = get("code") {
let code = figcraft_collab::code::normalize(&code).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN);
let code = code.ok_or("the link's join code isn't one")?;
return Ok((Invite { server, file: String::new(), token: String::new() }, Some(code)));
}
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
Ok(Invite { server, file, token })
Ok((Invite { server, file, token }, None))
}
}
+41 -2
View File
@@ -29,6 +29,9 @@ pub(crate) const CLOSE_RESTART: u16 = 1012;
#[derive(Clone, Debug)]
pub struct GuestConfig {
pub invite: Invite,
/// A join code to ask `invite.server` about first: its answer fills in the invite's file and
/// token ([`resolved`]).
pub code: Option<String>,
/// Your name (empty: the host picks one).
pub name: String,
pub limits: NetLimits,
@@ -38,18 +41,23 @@ pub struct GuestConfig {
pub max_backoff: Duration,
/// Reconnect attempts before giving up.
pub attempts: u32,
/// Join as the client you were (an app that starts over after a crash): the first hello
/// carries it, and a server that still knows the id gives it back.
pub resume: Option<Resume>,
}
impl GuestConfig {
pub fn new(invite: Invite, name: String) -> Self {
Self {
invite,
code: None,
name,
limits: NetLimits::default(),
connect_timeout: Duration::from_secs(5),
backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(10),
attempts: 12,
resume: None,
}
}
@@ -67,10 +75,36 @@ pub(crate) enum Outcome {
Refused(String),
/// The host ended the session: final.
Ended(String),
/// It can't go on, for this reason as it is shown: final.
Failed(String),
/// You left.
Left,
}
/// The server couldn't be asked about a join code, or said no: final, in words for the person who
/// typed it (the server's own for a code it doesn't know).
pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
Outcome::Failed(match outcome {
// A server from before join codes can't read the question.
Outcome::Refused(why) if why.contains("couldn't be read") => {
format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
}
Outcome::Refused(why) | Outcome::Failed(why) | Outcome::Ended(why) => why,
Outcome::Retry(why) => format!("Couldn't reach the server ({why})"),
Outcome::Left => return Outcome::Left,
})
}
/// The server's answer to the join code: the invite is whole now.
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into()));
}
cfg.code = None;
(cfg.invite.file, cfg.invite.token) = (file, token);
Ok(())
}
/// What a close frame from the host means.
pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
match code {
@@ -97,6 +131,7 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
match outcome {
Outcome::Left => Next::Stop(None),
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
Outcome::Failed(why) => failed(why),
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
Outcome::Retry(why) => {
@@ -207,8 +242,12 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
inbox.push(Event::Sync(m));
}
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
// Answers for managing and library connections: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) => {}
// Checked again by the engine (the host is as untrusted as any peer).
ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)),
ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)),
ServerMsg::Reported { error } => inbox.push(Event::Reported(error)),
// Answers for managing and library connections, and to a join code: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {}
}
Taken::Continue
}
+5
View File
@@ -19,6 +19,7 @@ pub mod inbox;
pub mod invite;
mod join;
pub mod limits;
mod sessions;
pub mod throttle;
#[cfg(not(target_arch = "wasm32"))]
@@ -51,6 +52,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip};
#[cfg(target_arch = "wasm32")]
pub use web::NetLink;
#[cfg(test)]
mod code_tests;
#[cfg(test)]
mod driver_tests;
#[cfg(test)]
@@ -62,4 +65,6 @@ mod loopback_tests;
#[cfg(test)]
mod session_tests;
#[cfg(test)]
mod sessions_tests;
#[cfg(test)]
mod tls_tests;
+37
View File
@@ -1,5 +1,6 @@
//! Transport limits, shared by the native transport and the browser client.
use std::net::IpAddr;
use std::time::Duration;
/// Transport limits (the collaboration core has its own: [`figcraft_collab::Limits`]).
@@ -27,6 +28,31 @@ pub struct NetLimits {
pub queue: usize,
/// How long a write may block.
pub write_timeout: Duration,
/// Most connections from one address at once (an IPv6 address counts by its /64; 0: no cap).
pub max_per_address: usize,
/// The reverse proxy in front of a host, whose `X-Forwarded-For` names the client: only
/// connections from this address are believed (the header is anyone's to forge).
pub trusted_proxy: Option<IpAddr>,
/// Bytes queued for one client before it counts as stuck (one message always fits an empty
/// queue), for one block of addresses (an IPv4 address, an IPv6 /48), and for every client
/// together (a broadcast counted once; past it the biggest consumer is dropped).
pub max_queued_bytes: usize,
pub max_queued_block: usize,
pub max_queued_total: usize,
/// Bytes being read into messages (not whole yet) for one block, and for every connection:
/// past them the biggest readers are dropped.
pub max_reading_block: usize,
pub max_reading_total: usize,
/// Bytes of messages handed to the hub and not handled yet, all connections together: past it
/// a connection is dropped as busy instead of piling more on.
pub max_in_flight: usize,
/// Largest message on a connection that only asks small things (browsing a plugin directory).
pub max_request: usize,
/// Connections only sessions (`Hello`) and the operator may take: one-shot requests leave them.
pub reserve_for_sessions: usize,
/// A one-shot request connection (hosting, a library, a plugin directory) with no traffic for
/// this long is dropped.
pub request_idle: Duration,
}
impl Default for NetLimits {
@@ -43,6 +69,17 @@ impl Default for NetLimits {
keepalive: Duration::from_secs(15),
queue: 1024,
write_timeout: Duration::from_secs(10),
max_per_address: 0,
trusted_proxy: None,
max_queued_bytes: 160 << 20,
max_queued_block: usize::MAX,
max_queued_total: usize::MAX,
max_reading_block: usize::MAX,
max_reading_total: usize::MAX,
max_in_flight: usize::MAX,
max_request: 64 << 10,
reserve_for_sessions: 0,
request_idle: Duration::from_secs(45),
}
}
}
+154 -53
View File
@@ -5,8 +5,12 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq};
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo};
use figcraft_collab::code;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_engine::live::{
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
SessionId, SessionInfo, Shared,
};
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
use crate::host::{Host, HostConfig};
@@ -14,6 +18,7 @@ use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::manage;
use crate::net::NetLimits;
use crate::sessions::Sessions;
use crate::token;
/// The port a hosted session tries first.
@@ -27,15 +32,23 @@ enum Active {
Guest(Guest),
}
/// Hosts or joins one session at a time.
impl Active {
/// Stop hosting, or leave.
fn end(self) {
match self {
Active::Host(mut h) => h.stop(),
Active::Guest(mut g) => g.leave(),
}
}
}
/// Hosts and joins sessions, any number at once (one per open document).
pub struct NetLink {
wake: Waker,
limits: NetLimits,
active: Option<Active>,
inbox: Option<Inbox>,
sessions: Sessions<Active>,
/// Tweaks for joining (tests shorten the reconnect pauses).
guest: fn(GuestConfig) -> GuestConfig,
/// Answers to library requests sent in the background (outlives sessions).
/// Answers to requests sent in the background (outlives sessions).
libraries: Inbox,
/// Background library requests are out.
fetching: Arc<AtomicBool>,
@@ -45,7 +58,7 @@ impl NetLink {
/// `wake` is called (from network threads) whenever something arrives for the UI.
pub fn new(wake: Waker) -> Self {
let libraries = Inbox::new(wake.clone());
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c, libraries, fetching: Arc::default() }
Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default() }
}
/// With other transport limits.
@@ -60,20 +73,27 @@ impl NetLink {
self
}
/// How this link joins `invite`.
fn guest_config(&self, invite: Invite, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
/// How this link joins `invite` (after asking its server about `code`, if there is one).
fn guest_config(&self, invite: Invite, code: Option<String>, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) });
cfg.limits = self.limits;
cfg
}
/// The running host, if hosting.
pub fn host_handle(&self) -> Option<&Host> {
match &self.active {
/// The running host of session `session`, if it is hosted here.
pub fn host_handle(&self, session: SessionId) -> Option<&Host> {
match self.sessions.get(session) {
Some(Active::Host(h)) => Some(h),
_ => None,
}
}
/// Session `session` started (one that had the id ends).
fn started(&mut self, session: SessionId, active: Active, inbox: Inbox) {
if let Some(old) = self.sessions.insert(session, active, inbox) {
old.end();
}
}
}
/// This machine's address on the local network: the interface the default route leaves by (no
@@ -94,15 +114,20 @@ pub fn lan_ip() -> Option<IpAddr> {
}
impl LiveLink for NetLink {
fn host(&mut self, o: HostOptions) -> Result<SessionInfo, String> {
self.leave();
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
let ip = match o.bind {
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
Bind::Lan => lan_ip().ok_or("this machine has no local network address (connect to a network, or share on this machine only)")?,
Bind::Addr(ip) => ip,
};
let (file, token) = (token::file_id()?, token::secret()?);
let inbox = Inbox::new(self.wake.clone());
// A session shared with a join code: its secret is short enough to be typed with the
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
let (file, token) = if o.short {
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
} else {
(token::file_id()?, token::secret()?)
};
let inbox = self.sessions.inbox();
let doc = (*o.doc).clone();
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
let file_name = doc.name.clone();
@@ -131,48 +156,75 @@ impl LiveLink for NetLink {
network: !ip.is_loopback(),
access: Access::Edit,
};
self.inbox = Some(inbox);
self.active = Some(Active::Host(host));
self.started(session, Active::Host(host), inbox);
Ok(info)
}
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
let cfg = self.guest_config(invite, name);
self.leave();
let inbox = Inbox::new(self.wake.clone());
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
self.join_resuming(session, link, name, None)
}
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, resume: Option<Resume>) -> Result<(), String> {
let (invite, code) = Invite::parse_join(link)?;
let cfg = GuestConfig { resume, ..self.guest_config(invite, code, name) };
let inbox = self.sessions.inbox();
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
self.started(session, Active::Guest(guest), inbox);
Ok(())
}
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
// Without a key: as anyone may, when the server is open to the public (it says if not).
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
self.leave();
let inbox = Inbox::new(self.wake.clone());
let job = Publish { server, key, name: o.name, doc: o.doc };
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
let inbox = self.sessions.inbox();
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
self.started(session, Active::Guest(guest), inbox);
Ok(())
}
fn upload(&mut self, o: PublishOptions) -> Result<Published, String> {
let (server, query) = Endpoint::parse(&o.server)?;
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
upload_file(&server, &key, &o.name, &o.doc, &self.limits, CONNECT).map(|(_, published)| published)
upload(o, &self.limits)
}
fn library(&mut self, link: &str, req: LibraryReq) -> Result<LibraryReply, String> {
manage::library(link, req, &self.limits, CONNECT)
}
fn plugins(&mut self, server: &str, req: PluginReq) -> Result<PluginReply, String> {
plugins(server, req, &self.limits)
}
fn hosting(&mut self, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
hosting(server, file, owner, req, &self.limits)
}
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
share(server, file, owner, revoke, create, &self.limits)
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
let (inbox, limits) = (self.libraries.clone(), self.limits);
let (failed, failure) = (inbox.clone(), req.failure());
let spawned = std::thread::Builder::new().name("server-request".into()).spawn(move || {
let reply = match req {
NetRequest::Plugins { server, req } => NetReply::Plugins(plugins(&server, req, &limits)),
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)),
};
inbox.push(Event::Net { job, reply: Box::new(reply) });
});
if let Err(e) = spawned {
failed.push(Event::Net { job, reply: Box::new(failure(format!("can't start a thread for the request: {e}"))) });
}
true
}
fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) {
if jobs.is_empty() || self.fetching.swap(true, Ordering::SeqCst) {
return;
@@ -191,40 +243,89 @@ impl LiveLink for NetLink {
}
}
fn leave(&mut self) {
match self.active.take() {
Some(Active::Host(mut h)) => h.stop(),
Some(Active::Guest(mut g)) => g.leave(),
None => {}
fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> {
match self.sessions.get(session) {
Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()),
Some(Active::Host(h)) => {
h.remove(client);
Ok(())
}
Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()),
None => Err("this session is over".into()),
}
self.inbox = None;
}
fn presence(&mut self, p: &Presence) {
match &self.active {
fn leave(&mut self, session: SessionId) {
if let Some(active) = self.sessions.remove(session) {
active.end();
}
}
fn presence(&mut self, session: SessionId, p: &Presence) {
match self.sessions.get(session) {
Some(Active::Host(h)) => h.presence(p.clone()),
Some(Active::Guest(g)) => g.presence(p.clone()),
None => {}
}
}
fn send(&mut self, msg: ClientMsg) {
match &self.active {
fn send(&mut self, session: SessionId, msg: ClientMsg) {
match self.sessions.get(session) {
Some(Active::Host(h)) => h.send(msg),
Some(Active::Guest(g)) => g.send(msg),
None => {}
}
}
fn poll(&mut self) -> Vec<Event> {
let mut out = self.libraries.drain();
out.extend(self.inbox.as_ref().map(Inbox::drain).unwrap_or_default());
fn poll(&mut self) -> Vec<(SessionId, Event)> {
let mut out: Vec<(SessionId, Event)> = self.libraries.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
out.extend(self.sessions.poll());
out
}
}
/// Keep `o.doc` on a server as a new file (see [`LiveLink::upload`]).
fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
let (server, query) = Endpoint::parse(&o.server)?;
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
}
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
/// its owner token (see [`LiveLink::hosting`]). A new link comes back whole, in place of its token.
fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &NetLimits) -> Result<(HostInfo, Option<AdminReply>), String> {
let (endpoint, _) = Endpoint::parse(server)?;
let (info, replies) = manage::hosting(&endpoint, file, owner, req.into_iter().collect(), limits, CONNECT)?;
let reply = replies.into_iter().next().map(|r| match r {
AdminReply::Link(mut l) => {
l.token = Invite { server: endpoint.clone(), file: l.link.file.clone(), token: l.token }.link();
AdminReply::Link(l)
}
other => other,
});
Ok((info, reply))
}
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
/// the file's owner.
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
let (endpoint, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
}
/// One request to the plugin directory at `server` (its key in the address's query, if any).
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
let (server, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default();
manage::plugins(&server, key.trim(), req, limits, CONNECT)
}
impl Drop for NetLink {
fn drop(&mut self) {
self.leave();
for id in self.sessions.ids() {
self.leave(id);
}
}
}
+88 -3
View File
@@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status};
@@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
use crate::guest::{Guest, GuestConfig};
use crate::host::{ENDED, Host, HostConfig};
use crate::host::{ENDED, Host, HostConfig, REMOVED};
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::net::{Bucket, NetLimits};
@@ -384,7 +384,7 @@ fn oversized_messages_end_the_connection() {
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id, .. }) = raw.next() else { panic!("no welcome") };
raw.send(&ClientMsg::Presence(Presence { selection: Some((0..5000).map(NodeId).collect()), ..Presence::default() }));
assert_eq!(raw.refusal().as_deref(), Some("message too large"));
assert!(raw.refusal().is_some_and(|r| r.starts_with("message too large")));
hp.wait("it left", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == client_id)));
// The host still serves others.
let (_a, mut ap) = join(&invite, "Ada");
@@ -557,3 +557,88 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
assert!(!hp.left().contains(&ada));
host.stop();
}
#[test]
fn signals_reach_only_clients_that_read_them_and_are_rate_limited() {
let (mut host, mut hp, invite) = start();
let addr: SocketAddr = invite.server.host.parse().unwrap();
hp.wait("the host's own ready", |e| e.iter().any(|e| matches!(e, Event::Signal(_, s) if s.kind == Signal::READY)));
// A client from before signals: its hello doesn't announce them.
let mut old = Raw::connect(addr);
let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Old", "") else { panic!("not a hello") };
assert!(old.send(&ClientMsg::Hello(Hello { client_version: "0.3.1".into(), ..h })));
assert!(matches!(old.next(), Some(ServerMsg::Welcome { .. })));
let mut new = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id: me, .. }) = new.next() else { panic!("no welcome") };
assert_eq!(new.next(), Some(ServerMsg::Signal { client_id: me, signal: Signal::ready() }));
// The host chats, then moves its pointer: the old client gets the pointer only.
host.send(ClientMsg::Signal(Signal::chat("hi")));
host.presence(presence(1.0, 2.0));
let until_presence = |r: &mut Raw| {
let mut got = Vec::new();
loop {
match r.next() {
Some(ServerMsg::Presence { .. }) | None => return got,
Some(m) => got.push(m),
}
}
};
let old_got = until_presence(&mut old);
assert!(!old_got.iter().any(|m| matches!(m, ServerMsg::Signal { .. })), "{old_got:?}");
let new_got = until_presence(&mut new);
let hana = host.me().client_id;
assert!(new_got.contains(&ServerMsg::Signal { client_id: hana, signal: Signal::chat("hi") }), "{new_got:?}");
// A flood of signals: the excess is dropped (the connection stays), the host gets them cleaned.
for i in 0..200 {
assert!(new.send(&ClientMsg::Signal(Signal::chat(&format!("{i}\u{7}")))));
}
assert!(new.send(&ClientMsg::Presence(presence(3.0, 4.0))));
hp.wait("the flood's end", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == me)));
let chats: Vec<&Signal> = hp.events.iter().filter_map(|e| if let Event::Signal(id, s) = e { (*id == me).then_some(s) } else { None }).collect();
assert!(chats.len() >= 40 && chats.len() < 200, "{} of 200 got through", chats.len());
assert!(chats.iter().all(|s| s.kind == Signal::CHAT && s.text.ends_with(' ')), "cleaned on the way");
assert!(new.send(&ClientMsg::Ping));
assert_eq!(until_presence_or_pong(&mut new), Some(ServerMsg::Pong), "still connected");
host.stop();
}
/// The next message that isn't someone's presence or signal.
fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
loop {
match r.next() {
Some(ServerMsg::Presence { .. } | ServerMsg::Signal { .. }) => {}
m => return m,
}
}
}
#[test]
fn the_host_removes_someone_and_they_cant_come_back() {
let (mut host, mut hp, invite) = start();
let (_a, mut ap) = join(&invite, "Ada");
let (b, mut bp) = join(&invite, "Bo");
ap.wait("welcome", welcomed);
bp.wait("welcome", welcomed);
let (ada, bo) = (ap.me(), bp.me());
connections_settle(&host, 2);
// The host can't remove themselves, nor someone who isn't there: nothing happens.
host.remove(host.me().client_id);
host.remove(9999);
host.remove(ada);
// Ada is told the session is over for her (no reconnecting); the others see her leave.
ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED)));
hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
connections_settle(&host, 1);
assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect");
assert!(!hp.left().contains(&bo), "only Ada was removed");
// Her address can't join again with the same invite (every guest here comes from 127.0.0.1).
let (_again, mut againp) = join(&invite, "Ada");
againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed"))));
assert!(!welcomed(&againp.events));
// Bo, already in, stays and can still talk to the host.
b.presence(presence(3.0, 4.0));
hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo)));
host.stop();
assert_eq!(host.connections(), 0);
}
+143 -3
View File
@@ -1,11 +1,16 @@
//! Requests to a server outside a live session, each on a connection of its own answered in
//! order: managing its files with the server's key ([`Manage`], [`request`]; the app publishes
//! through it, and the server's command line uses it to reach a running server), and using a
//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]).
//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]), and its
//! plugin directory ([`OpenPlugins`], [`plugins`]: anyone browses, the key publishes).
use std::time::Duration;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, PROTOCOL, ServerMsg};
use figcraft_collab::message::{
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Resolve, ServerMsg,
};
use figcraft_engine::live::{Shared, SharedLink};
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
@@ -37,6 +42,113 @@ pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetL
Ok(replies)
}
/// Ask the server at `server` about its hosting, with a file's owner token when `file` and `owner`
/// aren't empty; what it says, and its replies to `requests` (publishing without the key, managing
/// that file), in order. Fails when the server can't be reached or refuses (a wrong owner token, a
/// blocked address); a server older than open hosting can't read the request, and is reported as
/// taking files only with its key. Never echoes the token.
pub fn hosting(
server: &Endpoint,
file: &str,
owner: &str,
requests: Vec<Admin>,
limits: &NetLimits,
timeout: Duration,
) -> Result<(HostInfo, Vec<AdminReply>), String> {
let admin = |m: ServerMsg| match m {
ServerMsg::Admin(r) => Some(*r),
_ => None,
};
let open = OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), file: file.to_string(), owner: owner.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenHosting(open))?;
let info = match reply(&mut ws, limits, |m| match m {
ServerMsg::Hosting(i) => Some(*i),
_ => None,
}) {
Ok(i) => i,
Err(e) if e.contains("couldn't be read") => {
return Err(format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display()));
}
Err(e) => return Err(e),
};
let mut replies = Vec::with_capacity(requests.len());
for r in requests {
send(&mut ws, &ClientMsg::Admin(r))?;
replies.push(reply(&mut ws, limits, admin)?);
}
close(ws);
Ok((info, replies))
}
/// Ask the server at `server` which file join code `code` opens: the file's id and its link's
/// token. Fails with the server's own words when it knows no such code (or the address tried too
/// many: the error starts with [`REFUSED`]); a server older than join codes can't read the
/// question ("couldn't be read").
pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
let ask = Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::Resolve(ask))?;
let answer = reply(&mut ws, limits, |m| match m {
ServerMsg::Resolved { file, token } => Some((file, token)),
_ => None,
})?;
close(ws);
Ok(answer)
}
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
/// make a new edit link and a new view link and ask for their join codes: what others join with.
/// A server older than join codes makes the links without codes.
pub fn share(
server: &Endpoint,
key: Option<&str>,
file: &str,
owner: &str,
revoke: &[String],
create: bool,
limits: &NetLimits,
timeout: Duration,
) -> Result<Option<Shared>, String> {
let ask = |requests: Vec<Admin>| match key {
Some(key) => request(server, key, requests, limits, timeout),
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
};
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
let mut made = Vec::new();
for reply in ask(requests)? {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
};
// Their codes, on a connection of its own: a server from before join codes can't read the
// request and closes it (the links are shared without codes then).
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
.unwrap_or_default()
.into_iter()
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut shared = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = shared(edit);
Ok(Some(Shared { edit, view: Some(shared(view)) }))
}
/// Send one library request about the file behind share link `link`; the reply. Fails when the
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
/// [`LibraryReply::Failed`] reply). Never echoes the link.
@@ -59,6 +171,31 @@ pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duratio
Ok(r)
}
/// Send one request to the plugin directory of the server at `server`, with its `key` (empty: only
/// browsing); the reply. Fails when the server can't be reached, refuses the key or has no
/// directory (an older server); a request it can't do is a [`PluginReply::Failed`] reply. Never
/// echoes the key.
pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits, timeout: Duration) -> Result<PluginReply, String> {
let pick = |m: ServerMsg| match m {
ServerMsg::Plugins(r) => Some(*r),
_ => None,
};
let open = OpenPlugins { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenPlugins(open))?;
match reply(&mut ws, limits, pick) {
Ok(PluginReply::Ready { .. }) => {}
// An older server can't read the request at all.
Err(e) if e.contains("couldn't be read") => return Err(format!("{} has no plugin directory (update the server)", server.display())),
Err(e) => return Err(e),
Ok(other) => return Err(format!("the server answered {other:?} instead of opening its plugin directory")),
}
send(&mut ws, &ClientMsg::Plugins(req))?;
let r = reply(&mut ws, limits, pick)?;
close(ws);
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).
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
@@ -80,12 +217,15 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
}
/// How an error starts when the server said no (then its reason), rather than not answering.
pub(crate) const REFUSED: &str = "the server refused: ";
/// The next answer `pick` takes, or why there is none.
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
Ok(ServerMsg::Pong) => continue,
Ok(m) => pick(m).ok_or_else(|| "the server sent something other than an answer".into()),
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
+155 -5
View File
@@ -6,8 +6,10 @@
//! machines over the same socket; every write goes through one lock and writes whole frames, so
//! frames from the two never interleave.
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
@@ -29,16 +31,150 @@ pub struct Duplex {
read: TcpStream,
write: Arc<Mutex<TcpStream>>,
tls: Option<Tls>,
/// What the bytes read count against (a server's reader), if anything.
meter: Option<Meter>,
}
impl Duplex {
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
let write = Arc::new(Mutex::new(stream.try_clone()?));
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone() };
let b = Duplex { read: stream.try_clone()?, write, tls };
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
Ok((a, b, stream))
}
/// Count what this handle reads against `meter`.
pub fn meter(&mut self, meter: Meter) {
self.meter = Some(meter);
}
/// What was read so far became a message (handed on): it no longer counts as being read.
pub fn read_done(&mut self) {
if let Some(m) = self.meter.as_mut() {
m.release();
}
}
}
/// Every reader that counts what it reads, with what it holds half-read and its socket: when
/// everyone's reading budget runs out, the reader holding the most is cut off.
#[derive(Clone, Default)]
pub struct Readers(Arc<Mutex<HashMap<u64, Reader>>>);
/// One registered reader: what it holds half-read, its block's count, whether it was cut off, and
/// its socket.
struct Reader {
mine: Arc<AtomicUsize>,
block: Arc<AtomicUsize>,
shed: Arc<AtomicBool>,
sock: TcpStream,
}
impl Readers {
/// Cut off the reader holding the most half-read bytes, unless that is `me` (then `false`: the
/// caller is the biggest). Its bytes are given back to the budget at once, it can't read any
/// more, and its socket is shut (where that wakes a blocked read, it ends now; elsewhere at its
/// read timeout).
fn shed_biggest(&self, me: u64, all: &AtomicUsize) -> bool {
let t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
let Some((&id, r)) = t.iter().filter(|(_, r)| !r.shed.load(Ordering::Relaxed)).max_by_key(|(_, r)| r.mine.load(Ordering::Relaxed)) else {
return false;
};
if id == me || r.mine.load(Ordering::Relaxed) == 0 {
return false;
}
log::info!("event=shed conn={id} reason=reading_bytes");
r.shed.store(true, Ordering::Relaxed);
let held = r.mine.swap(0, Ordering::Relaxed);
r.block.fetch_sub(held, Ordering::Relaxed);
all.fetch_sub(held, Ordering::Relaxed);
let _ = r.sock.shutdown(Shutdown::Both);
true
}
/// Readers registered.
pub fn len(&self) -> usize {
self.0.lock().unwrap_or_else(PoisonError::into_inner).len()
}
/// None registered.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Bytes being read into messages (not whole yet), per connection, per block of addresses and
/// for every connection together. A read that takes its block over its share fails. One that takes
/// everyone over the budget cuts off the readers holding the most until it fits (their bytes are
/// given back at once, they can't read any more and their sockets are shut) or, when this reader is
/// the biggest, fails: the biggest readers go, not the next one.
pub struct Meter {
id: u64,
mine: Arc<AtomicUsize>,
shed: Arc<AtomicBool>,
block: Arc<AtomicUsize>,
all: Arc<AtomicUsize>,
max_block: usize,
max_all: usize,
readers: Readers,
}
/// What a reader cut off for using too much of the budget is told.
const BUSY_READING: &str = "the server is busy reading from others: try again in a moment";
impl Meter {
/// Connection `id`'s meter (registered with `readers`, cut off through `sock`).
pub fn new(
id: u64,
sock: &TcpStream,
readers: &Readers,
block: Arc<AtomicUsize>,
all: Arc<AtomicUsize>,
max_block: usize,
max_all: usize,
) -> Self {
let (mine, shed) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)));
if let Ok(s) = sock.try_clone() {
let r = Reader { mine: mine.clone(), block: block.clone(), shed: shed.clone(), sock: s };
readers.0.lock().unwrap_or_else(PoisonError::into_inner).insert(id, r);
}
Self { id, mine, shed, block, all, max_block, max_all, readers: readers.clone() }
}
fn add(&mut self, n: usize) -> io::Result<()> {
if self.shed.load(Ordering::Relaxed) {
return Err(io::Error::other(BUSY_READING));
}
self.mine.fetch_add(n, Ordering::Relaxed);
let block = self.block.fetch_add(n, Ordering::Relaxed).saturating_add(n);
let mut all = self.all.fetch_add(n, Ordering::Relaxed).saturating_add(n);
if block > self.max_block {
return Err(io::Error::other(BUSY_READING));
}
// Over everyone's budget: the biggest readers are cut off until it fits, or this one goes
// when it is the biggest.
while all > self.max_all {
if !self.readers.shed_biggest(self.id, &self.all) {
return Err(io::Error::other(BUSY_READING));
}
all = self.all.load(Ordering::Relaxed);
}
Ok(())
}
fn release(&mut self) {
let mine = self.mine.swap(0, Ordering::Relaxed);
self.block.fetch_sub(mine, Ordering::Relaxed);
self.all.fetch_sub(mine, Ordering::Relaxed);
}
}
impl Drop for Meter {
fn drop(&mut self) {
self.release();
self.readers.0.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.id);
}
}
impl Duplex {
@@ -51,10 +187,14 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match &self.tls {
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}?;
if let Some(m) = self.meter.as_mut() {
m.add(n)?;
}
Ok(n)
}
}
@@ -120,13 +260,23 @@ impl Bucket {
/// Spend one token at `now`; false when there are none left.
pub fn take(&mut self, now: Instant) -> bool {
self.take_n(now, 1.0)
}
/// Spend `n` tokens at `now`; false (spending none) when there aren't that many.
pub fn take_n(&mut self, now: Instant, n: f64) -> bool {
let dt = now.saturating_duration_since(self.at).as_secs_f64();
self.at = now;
self.tokens = (self.tokens + dt * self.rate).min(self.burst);
if self.tokens < 1.0 {
if self.tokens < n {
return false;
}
self.tokens -= 1.0;
self.tokens -= n;
true
}
/// Give back `n` tokens (up to the burst).
pub fn give(&mut self, n: f64) {
self.tokens = (self.tokens + n).min(self.burst);
}
}
+122
View File
@@ -0,0 +1,122 @@
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
//! broadcast counts once against the server's budget however many copies are queued, a block of
//! addresses can't take more than its share, and when the server's budget is used up the
//! connection holding the most is shed, not the next client that needs a little.
use std::collections::HashMap;
use std::net::{IpAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, sync_channel};
use tungstenite::Bytes;
use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
/// A line with its own queue counter, the given block counter and the server's.
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
let (tx, rx) = sync_channel(64);
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
}
fn socket() -> TcpStream {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
TcpStream::connect(l.local_addr().unwrap()).unwrap()
}
#[test]
fn a_broadcast_counts_once_against_the_server() {
let all = Arc::new(AtomicUsize::new(0));
let lines: Vec<_> = (0..4).map(|_| line(&Arc::default(), &all, usize::MAX, 10_000)).collect();
let b = Bytes::from(vec![0u8; 3_000]);
let mut held = Charges::default();
for (l, _) in &lines {
l.push(&b, &mut held, false).unwrap();
}
assert_eq!(all.load(Ordering::Relaxed), 3_000, "four copies of one message, counted once");
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 3_000), "each queue counts its copy");
drop(held);
// Sent (or dropped): the counts go back as the copies go.
for (_, rx) in &lines {
drop(rx.try_recv().unwrap());
}
assert_eq!(all.load(Ordering::Relaxed), 0);
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 0));
}
#[test]
fn one_block_gets_only_its_share() {
let (block, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let (a, _ra) = line(&block, &all, 1_000, usize::MAX);
let (b, _rb) = line(&block, &all, 1_000, usize::MAX);
let (c, _rc) = line(&Arc::default(), &all, 1_000, usize::MAX);
let msg = Bytes::from(vec![0u8; 800]);
a.push(&msg, &mut Charges::default(), false).unwrap();
assert_eq!(b.push(&msg, &mut Charges::default(), false), Err(Push::Full), "the block's second connection is over its share");
c.push(&msg, &mut Charges::default(), false).unwrap();
}
#[test]
fn the_biggest_consumer_is_shed_when_the_budget_runs_out() {
let all = Arc::new(AtomicUsize::new(0));
let ip: IpAddr = "203.0.113.1".parse().unwrap();
let mut conns = HashMap::new();
let mut receivers = Vec::new();
for id in 1..=3u64 {
let (l, rx) = line(&Arc::default(), &all, usize::MAX, 10_000);
receivers.push(rx);
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing, ip });
}
// Connection 1 doesn't read: it holds most of the budget.
let big = Bytes::from(vec![0u8; 9_000]);
assert!(deliver(&conns, 1, &big, &mut Charges::default(), &mut Vec::new()));
// Connection 2 needs a little: the hog is shed and 2 still gets its message.
let small = Bytes::from(vec![0u8; 2_000]);
let mut shed = Vec::new();
assert!(deliver(&conns, 2, &small, &mut Charges::default(), &mut shed));
assert_eq!(shed, vec![1]);
assert_eq!(conns[&2].out.queued.load(Ordering::Relaxed), 2_000);
// When the receiver is itself the biggest, it is the one to go.
let mut shed = Vec::new();
assert!(!deliver(&conns, 1, &small, &mut Charges::default(), &mut shed), "the hog asking for more is dropped");
assert!(shed.is_empty());
}
#[test]
fn a_broadcast_counts_once_per_block_and_welcomes_not_at_all() {
let (office, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
// Twenty people behind one office's address, a block share of 3 MB.
let lines: Vec<_> = (0..20).map(|_| line(&office, &all, 3_000_000, usize::MAX)).collect();
// A 2 MB paste to all of them: one copy against the block, so everyone gets it.
let paste = Bytes::from(vec![0u8; 2_000_000]);
let mut charges = Charges::default();
for (l, _) in &lines {
l.push(&paste, &mut charges, false).unwrap();
}
assert_eq!(office.load(Ordering::Relaxed), 2_000_000);
drop(charges);
for (_, rx) in &lines {
drop(rx.try_recv().unwrap());
}
assert_eq!(office.load(Ordering::Relaxed), 0);
// Twenty welcomes of a large file at once (each its own message): not counted against the block.
let welcome = Bytes::from(vec![0u8; 1_000_000]);
for (l, _) in &lines {
l.push(&welcome, &mut Charges { skip_blocks: true, ..Charges::default() }, false).unwrap();
}
assert_eq!(office.load(Ordering::Relaxed), 0);
assert_eq!(all.load(Ordering::Relaxed), 20_000_000, "they still count against the server's budget");
}
#[test]
fn blocks_nobody_uses_are_forgotten() {
let blocks = Blocks::default();
let a: IpAddr = "203.0.113.1".parse().unwrap();
let held: BlockUse = blocks.get(a);
let _b = blocks.get("203.0.113.2".parse().unwrap());
assert_eq!(blocks.len(), 2);
drop(held);
// The next connection prunes the block whose connections are all gone.
let _c = blocks.get("203.0.113.3".parse().unwrap());
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
}
+56 -19
View File
@@ -47,10 +47,10 @@ fn host_and_join() -> (Session, Session, u32, u32) {
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
assert_eq!(r["status"], "connecting");
pump(&mut host, &mut guest, "the join", |h, g| {
g.live.status == Status::Live && h.live.peers.len() == 1 && g.live.info.as_ref().is_some_and(|i| !i.color.is_empty())
g.live().status == Status::Live && h.live().peers.len() == 1 && g.live().info.as_ref().is_some_and(|i| !i.color.is_empty())
});
let hana = host.live.info.as_ref().unwrap().me;
let ada = guest.live.info.as_ref().unwrap().me;
let hana = host.live().info.as_ref().unwrap().me;
let ada = guest.live().info.as_ref().unwrap().me;
(host, guest, hana, ada)
}
@@ -62,23 +62,23 @@ fn host_join_and_see_each_other() {
assert_eq!(g["fileName"], "Untitled", "the host's file name");
assert_eq!(g["name"], "Ada");
assert_eq!(g["peers"][0]["name"], "Hana");
assert_eq!(host.live.peers[&ada].name, "Ada");
assert_eq!(host.live().peers[&ada].name, "Ada");
// The pointer: the guest's cursor shows up in the host's state.
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
h.live().presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
});
// A selection travels too.
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let id = r["id"].as_u64().unwrap();
host.execute("select.set", &json!({"ids": [id]})).unwrap();
pump(&mut host, &mut guest, "the host's selection", |_, g| {
g.live.presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
g.live().presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
});
// Leaving: the host sees it, the guest is back to no session.
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
assert_eq!(guest.live.status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live.peers.is_empty() && h.live.presence.is_empty());
assert_eq!(guest.live().status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live().peers.is_empty() && h.live().presence.is_empty());
host.execute("collab.stop", &json!({})).unwrap();
assert_eq!(host.live_summary()["status"], "off");
}
@@ -87,7 +87,7 @@ fn host_join_and_see_each_other() {
fn following_a_viewport_until_you_move() {
let (mut host, mut guest, hana, ada) = host_and_join();
host.execute("collab.follow", &json!({"client": ada})).unwrap();
assert_eq!(host.live.follow, Some(ada));
assert_eq!(host.live().follow, Some(ada));
// The guest pans and zooms: the host's view goes along.
{
let ds = guest.ds_mut().unwrap();
@@ -103,29 +103,29 @@ fn following_a_viewport_until_you_move() {
// The host pans: following stops, and later moves of the guest don't move the host.
host.ds_mut().unwrap().view.cx += 100.0;
host.live_sync();
assert_eq!(host.live.follow, None);
assert_eq!(host.live().follow, None);
guest.ds_mut().unwrap().view.cx = -9000.0;
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
h.live().presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
});
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
// Escape stops following too; following someone who isn't there fails.
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
guest.escape().unwrap();
assert_eq!(guest.live.follow, None);
assert_eq!(guest.live().follow, None);
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
// The followed peer leaving ends the follow.
host.execute("collab.follow", &json!({"client": ada})).unwrap();
guest.execute("collab.leave", &json!({})).unwrap();
pump(&mut host, &mut guest, "the leave", |h, _| h.live.follow.is_none() && h.live.peers.is_empty());
pump(&mut host, &mut guest, "the leave", |h, _| h.live().follow.is_none() && h.live().peers.is_empty());
}
#[test]
fn the_host_stopping_ends_the_session_for_guests() {
let (mut host, mut guest, _, _) = host_and_join();
host.execute("collab.stop", &json!({})).unwrap();
pump(&mut host, &mut guest, "the end", |_, g| g.live.status == Status::Off);
assert!(guest.live.peers.is_empty());
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
assert!(guest.live().peers.is_empty());
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
}
@@ -138,8 +138,8 @@ fn a_bad_invite_is_reported() {
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
guest.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live.status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live.peers.is_empty());
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live().status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live().peers.is_empty());
}
/// The synced properties of two documents agree (nothing to send from one to the other).
@@ -175,7 +175,7 @@ fn edits_sync_between_apps() {
}
};
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
h.live.syncing() && a.live.syncing() && b.live.syncing() && h.live.peers.len() == 2
h.live().syncing() && a.live().syncing() && b.live().syncing() && h.live().peers.len() == 2
});
// The guests got the host's file as a new tab.
assert_eq!(a.docs.len(), 2);
@@ -208,7 +208,44 @@ fn edits_sync_between_apps() {
// And the server's: someone joining now gets its document in the welcome.
let mut c = session("Cy");
c.execute("collab.join", &json!({"link": link})).unwrap();
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live.syncing());
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live().syncing());
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
host.execute("collab.stop", &json!({})).unwrap();
}
#[test]
fn cursor_chat_and_spotlight_between_apps() {
let (mut host, mut guest, hana, ada) = host_and_join();
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
// The guest chats next to its cursor; the host sees it, cleaned, and then sees it close.
guest.execute("collab.cursor", &json!({"x": 40, "y": 30})).unwrap();
guest.execute("collab.cursorChat", &json!({"text": "over\there"})).unwrap();
pump(&mut host, &mut guest, "the chat", |h, _| h.live().chats.get(&ada).is_some_and(|t| t == "over here"));
guest.execute("collab.cursorChat", &json!({"text": ""})).unwrap();
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live().chats.is_empty());
// The host spotlights: the guest follows its view, page included.
let second = host.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
pump(&mut host, &mut guest, "the new page at the guest", |_, g| g.doc().unwrap().pages.contains(&second));
assert_eq!(host.execute("collab.spotlight", &json!({})).unwrap()["spotlighting"], true);
{
let ds = host.ds_mut().unwrap();
ds.page = second;
ds.view.cx = 777.0;
ds.view.cy = -55.0;
}
pump(&mut host, &mut guest, "the guest following the spotlight", |_, g| {
let ds = g.ds().unwrap();
g.live().spotlight == Some(hana) && g.live().follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
});
// A late joiner is told and follows too.
let mut late = session("Lin");
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live().follow == Some(hana) && l.live().spotlight == Some(hana));
// The guest moves: it stops following, the spotlight stays; the host stops it for everyone.
guest.ds_mut().unwrap().view.cx += 300.0;
guest.live_sync();
assert_eq!((guest.live().follow, guest.live().spotlight), (None, Some(hana)));
host.execute("collab.spotlight", &json!({"on": false})).unwrap();
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none());
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none());
}
+52
View File
@@ -0,0 +1,52 @@
//! The sessions a link carries at once (the engine has one per document): each under the id the
//! engine gave it, with its own [`Inbox`], so what happens in one never reaches another and a
//! session that ended can't reach the next. The desktop link and the browser link both keep
//! theirs here.
use std::collections::BTreeMap;
use figcraft_engine::live::{Event, SessionId};
use crate::inbox::{Inbox, Waker};
/// Running sessions of kind `T` (a host or a guest connection) by id.
pub(crate) struct Sessions<T> {
wake: Waker,
running: BTreeMap<SessionId, (T, Inbox)>,
}
impl<T> Sessions<T> {
pub(crate) fn new(wake: Waker) -> Self {
Self { wake, running: BTreeMap::new() }
}
/// A mailbox for a session about to start.
pub(crate) fn inbox(&self) -> Inbox {
Inbox::new(self.wake.clone())
}
/// Session `id` started, with the mailbox its threads fill. Returns the session that had
/// the id, to be ended.
pub(crate) fn insert(&mut self, id: SessionId, session: T, inbox: Inbox) -> Option<T> {
self.running.insert(id, (session, inbox)).map(|(old, _)| old)
}
/// Session `id` is over: it comes back to be ended, and its mailbox is dropped.
pub(crate) fn remove(&mut self, id: SessionId) -> Option<T> {
self.running.remove(&id).map(|(session, _)| session)
}
pub(crate) fn get(&self, id: SessionId) -> Option<&T> {
self.running.get(&id).map(|(session, _)| session)
}
/// Every session's id, oldest first.
pub(crate) fn ids(&self) -> Vec<SessionId> {
self.running.keys().copied().collect()
}
/// What happened in each session since the last call, with its id.
pub(crate) fn poll(&self) -> Vec<(SessionId, Event)> {
self.running.iter().flat_map(|(id, (_, inbox))| inbox.drain().into_iter().map(|e| (*id, e))).collect()
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Sessions per document over the network: the link's session table, and two engine sessions in
//! one process that host and join several files at once through `collab.*` commands. Each file's
//! session has its own people, links, edits and presence; closing a file ends its session only.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::{Event, Live, Presence, Role, Status};
use serde_json::{Value, json};
use crate::NetLink;
use crate::loopback_tests::quick;
use crate::sessions::Sessions;
#[test]
fn each_session_has_its_own_mailbox() {
let woken = Arc::new(AtomicUsize::new(0));
let wake = woken.clone();
let mut table: Sessions<&str> = Sessions::new(Arc::new(move || {
wake.fetch_add(1, Ordering::SeqCst);
}));
let (a, b) = (table.inbox(), table.inbox());
assert!(table.insert(1, "host", a.clone()).is_none());
assert!(table.insert(2, "guest", b.clone()).is_none());
a.push(Event::PeerLeft(7));
b.push(Event::PeerLeft(9));
b.presence(3, Presence::default());
assert_eq!(woken.load(Ordering::SeqCst), 3, "every arrival wakes the UI");
assert_eq!(table.poll(), vec![(1, Event::PeerLeft(7)), (2, Event::PeerLeft(9)), (2, Event::Presence(3, Presence::default()))]);
assert!(table.poll().is_empty(), "taken once");
// A session that ended: what its threads still send goes nowhere, the other goes on.
assert_eq!(table.remove(1), Some("host"));
a.push(Event::PeerLeft(8));
b.push(Event::PeerLeft(10));
assert_eq!(table.poll(), vec![(2, Event::PeerLeft(10))]);
assert_eq!((table.ids(), table.get(1), table.get(2)), (vec![2], None, Some(&"guest")));
// An id started again replaces what had it, mailbox included.
assert_eq!(table.insert(2, "again", table.inbox()), Some("guest"));
b.push(Event::PeerLeft(11));
assert!(table.poll().is_empty());
assert_eq!(table.remove(3), None);
}
fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump the sessions (as their UIs would every frame) until `f` holds.
fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
let start = Instant::now();
loop {
for s in all.iter_mut() {
s.live_poll();
s.live_sync();
}
if f(all) {
return;
}
let states: Vec<String> = all.iter().map(|s| s.live_summary()["sessions"].to_string()).collect();
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {states:?}");
std::thread::sleep(Duration::from_millis(5));
}
}
fn link_of(v: &Value) -> String {
v["link"].as_str().unwrap().to_string()
}
fn uid_at(s: &Session, index: usize) -> u64 {
s.docs[index].uid
}
/// The session syncing through the host at `address` (a guest's).
fn joined<'a>(s: &'a Session, address: &str) -> Option<&'a Live> {
s.live.iter().find(|l| l.syncing() && l.info.as_ref().is_some_and(|i| i.role == Role::Guest && i.address == address))
}
fn has(s: &Session, uid: u64, node: u64) -> bool {
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.contains(NodeId(node)))
}
fn rect(s: &mut Session) -> u64 {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
}
#[test]
fn two_files_are_hosted_and_joined_independently() {
let mut host = session("Hana");
let mut guest = session("Ada");
// File A is hosted; a new file B isn't in a session until it is hosted too.
let a = host.execute("collab.host", &json!({"port": 0})).unwrap();
host.execute("file.new", &json!({"name": "Second"})).unwrap();
let off = host.execute("collab.peers", &json!({})).unwrap();
assert_eq!((&off["status"], &off["session"], &off["link"], &off["document"]), (&json!("off"), &Value::Null, &Value::Null, &Value::Null));
assert_eq!(off["sessions"].as_array().unwrap().len(), 1, "the first file's session goes on: {off}");
assert_eq!(off["sessions"][0]["document"], 0);
let b = host.execute("collab.host", &json!({"port": 0})).unwrap();
assert_ne!(a["address"], b["address"], "each file listens on its own port");
assert_ne!(a["session"], b["session"]);
assert_eq!(b["sessions"].as_array().unwrap().len(), 2);
let (addr_a, addr_b) = (a["address"].as_str().unwrap(), b["address"].as_str().unwrap());
let (ha, hb) = (uid_at(&host, 0), uid_at(&host, 1));
// The guest joins both, one right after the other: two more tabs, each with its session.
for l in [&a, &b] {
assert_eq!(guest.execute("collab.join", &json!({"link": link_of(l)})).unwrap()["status"], "connecting");
}
pump(&mut [&mut host, &mut guest], "both joins", |s| {
joined(&*s[1], addr_a).is_some() && joined(&*s[1], addr_b).is_some() && s[0].live_of(ha).peers.len() == 1 && s[0].live_of(hb).peers.len() == 1
});
assert_eq!(guest.docs.len(), 3, "the guest's own file and the two it joined");
let (ga, gb) = (joined(&guest, addr_a).unwrap().doc_uid.unwrap(), joined(&guest, addr_b).unwrap().doc_uid.unwrap());
assert_ne!(ga, gb);
assert_eq!(guest.live_of(uid_at(&guest, 0)).status, Status::Off, "the file the joins were started from is in no session");
assert_eq!(guest.docs.iter().find(|d| d.uid == gb).unwrap().doc.name, "Second");
// `collab.peers` is the file in front's: switching files switches it.
assert_eq!(host.live_summary()["link"], b["link"]);
host.execute("doc.switch", &json!({"index": 0})).unwrap();
assert_eq!(host.live_summary()["link"], a["link"]);
assert_eq!(host.live_summary()["document"], 0);
// 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);
host.execute("node.set", &json!({"ids": [in_b], "props": {"name": "Only in B"}})).unwrap();
let name = |s: &Session, uid: u64, node: u64| s.docs.iter().find(|d| d.uid == uid).and_then(|d| d.doc.node(NodeId(node))).map(|n| n.name.clone());
// The rename is its own batch, behind the one that creates the layer: wait for it too.
pump(&mut [&mut host, &mut guest], "the edits at the guest", |s| has(&*s[1], ga, in_a) && name(&*s[1], gb, in_b).as_deref() == Some("Only in B"));
assert_ne!(name(&guest, ga, in_b).as_deref(), Some("Only in B"), "B's edit never reaches A");
assert_ne!(name(&host, ha, in_b).as_deref(), Some("Only in B"));
// And back: the guest edits its copy of A in the background tab's session.
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == ga).unwrap()})).unwrap();
let from_guest = rect(&mut guest);
guest.execute("node.set", &json!({"ids": [from_guest], "props": {"name": "From Ada"}})).unwrap();
pump(&mut [&mut host, &mut guest], "the guest's edit at the host", |s| name(&*s[0], ha, from_guest).as_deref() == Some("From Ada"));
assert_ne!(name(&host, hb, from_guest).as_deref(), Some("From Ada"));
// Presence stays in its session: the guest points in A (in front there), not in B.
let ada = |s: &Session, uid: u64| s.live_of(uid).peers.keys().next().copied().unwrap();
guest.execute("collab.cursor", &json!({"x": 64, "y": 48})).unwrap();
pump(&mut [&mut host, &mut guest], "the guest's cursor in A", |s| {
s[0].live_of(ha).presence.get(&ada(&*s[0], ha)).and_then(|p| p.cursor).is_some_and(|c| c.1 == 64.0 && c.2 == 48.0)
});
assert!(host.live_of(hb).presence.get(&ada(&host, hb)).is_none_or(|p| p.cursor.is_none()), "no cursor in the file that isn't in front");
// The host closes B: its session ends for everyone, A's goes on.
host.execute("file.close", &json!({"index": 1, "force": true})).unwrap();
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live.iter().count() == 1);
assert_eq!(guest.live_of(gb).status, Status::Off);
assert_eq!(guest.docs.len(), 3, "the guest keeps its copy of B");
assert_eq!((host.live_of(ha).status.clone(), host.live_of(ha).peers.len()), (Status::Live, 1));
assert_eq!(guest.live_of(ga).status, Status::Live);
let later = rect(&mut host);
pump(&mut [&mut host, &mut guest], "an edit after B closed", |s| has(&*s[1], ga, later));
assert!(!has(&guest, gb, later));
}
#[test]
fn hosting_one_file_while_joined_to_another() {
let mut x = session("Hana");
let mut y = session("Ada");
let from_x = link_of(&x.execute("collab.host", &json!({"port": 0})).unwrap());
let from_y = link_of(&y.execute("collab.host", &json!({"port": 0})).unwrap());
let (xu, yu) = (uid_at(&x, 0), uid_at(&y, 0));
// Each joins the other's file from the tab it hosts: the hosting goes on.
y.execute("collab.join", &json!({"link": from_x})).unwrap();
x.execute("collab.join", &json!({"link": from_y})).unwrap();
pump(&mut [&mut x, &mut y], "both joined", |s| {
s.iter().all(|s| s.docs.len() == 2 && s.live().syncing() && s.live().role() == Some(Role::Guest))
&& s[0].live_of(xu).peers.len() == 1
&& s[1].live_of(yu).peers.len() == 1
});
for (s, own) in [(&x, xu), (&y, yu)] {
let p = s.live_summary();
assert_eq!((&p["role"], &p["document"]), (&json!("guest"), &json!(1)), "the joined file is in front: {p}");
assert_eq!(p["sessions"].as_array().unwrap().len(), 2);
assert_eq!(s.live_of(own).role(), Some(Role::Host));
assert_eq!(s.live_of(own).status, Status::Live);
}
// Y edits X's file (its tab in front) and its own; each lands in its own file at X.
let in_x = rect(&mut y);
y.execute("doc.switch", &json!({"index": 0})).unwrap();
let in_y = rect(&mut y);
y.execute("node.set", &json!({"ids": [in_y], "props": {"name": "Hosted by Ada"}})).unwrap();
let named = |s: &Session, uid: u64| {
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.node(NodeId(in_y)).is_some_and(|n| n.name == "Hosted by Ada"))
};
let x_copy_of_y = x.live().doc_uid.unwrap();
pump(&mut [&mut x, &mut y], "Y's edits at X", |s| has(&*s[0], xu, in_x) && named(&*s[0], x_copy_of_y));
assert!(!named(&x, xu), "the edit of Y's file isn't in X's");
// Y leaves X's file: Y still hosts its own, with X in it.
y.execute("doc.switch", &json!({"index": 1})).unwrap();
assert_eq!(y.execute("collab.leave", &json!({})).unwrap()["ended"], true);
pump(&mut [&mut x, &mut y], "the leave", |s| s[0].live_of(xu).peers.is_empty());
assert_eq!((y.live().status.clone(), y.live.iter().count()), (Status::Off, 1));
assert_eq!((y.live_of(yu).status.clone(), y.live_of(yu).peers.len()), (Status::Live, 1));
assert_eq!(x.live_of(x_copy_of_y).status, Status::Live);
}
Loaded 100 of 609 files, more files were not shown because too many files have changed in this diff. Show more