Compare commits

...
Author SHA1 Message Date
bflatastic a2aa05a014 Merge main into #1648 2026-10-30 19:11:02 +07:00
BFlat 4965620d77 M6: pasted layers keep the colours of their shared paint styles (#1647)
* M6: pasted layers keep the colours of their shared paint styles (#1638)

A clipboard links a style by the guid it has in its library, which it doesn't hold, and by its library asset, whose copy it does hold. Mapper::resource_id now takes a guid only when the file holds a node by it, else the asset, so .fig import and paste agree; a link to a style that really isn't there keeps the layer's stored colours, with a note.

* M6: Swedish catalog: keep both new import notes after the merge
2026-10-30 18:54:24 +07:00
bflatastic 4b287d5a3c Merge main into #1648 2026-10-30 18:43:45 +07:00
BFlat dd2a9fd013 Docs: one copy of each row and status item, and a check that keeps it so (#1658)
* Docs: one copy of each row and status item, and a check that keeps it so

Merge resolutions that kept both sides had left progress-doc rows (the .fig row four times, co-editing, live cursors, Windows, M0.9) and status-line items (302 in all) twice or more. The rows are merged keeping every distinct fact, the newest wording winning (#1649's connector routing included); repeated status items are removed, the first kept. cargo xtask docs, run by ci, fails on a repeated table row or status item.

* Docs: remove the status items the merge of main brought back

Merging main into #1658 rebuilt the status lines from main's copies, which still listed items twice: ROADMAP 2, docs/roadmap.md 5, docs/gaps.md 43 and docs/target-app-parity.md 190, whose unclosed parenthesis also came back and hid them from cargo xtask docs. They are removed again, the first copy kept, and the parenthesis closed; every distinct item is still present against main.
2026-10-30 18:36:00 +07:00
bflatastic e4d7318ebb Merge main into #1648 2026-10-30 18:03:12 +07:00
BFlat e4a7e89f39 M3: typed and stored variable, collection and mode ids find exactly that object (#1613) (#1635)
Builds on #1618. Variables::get and Variables::collection take an exact id before a name; stored mode ids go through Collection::mode_by_id (expressions, the prototype's mode switch); font family variables and the colour picker, prototype editor and panels read stored ids exactly. Ported from fix/stored-ids-exact.
2026-10-30 18:00:34 +07:00
bflatastic e9701e82f1 Merge remote-tracking branch 'origin/main' into res1648
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/locales/id.toml
#	docs/file-format-parity.md
#	docs/roadmap.md
2026-10-30 17:52:27 +07:00
BFlat f0abe51a84 M6: plugin widgets have hover styles (gap 6) (#1634) 2026-10-30 17:33:04 +07:00
BFlat 9099f1d5ae M6: .fig elbowed and curved connectors without a saved path are routed, not drawn straight (#1649)
The board connector router moves, unchanged, from the engine into figcraft-doc (route_points, RouteEnd) so the .fig importer can use it: an elbowed or curved connector saved without its path runs in right angles or one curve from the side of each layer it joins (the file's magnet, else the edge its point is on, else the side facing the other end). Straight connectors are unchanged. Gap 1.
2026-10-30 17:11:22 +07:00
BFlat 3d7ca30d51 M6: plugin panels take a slider control (gap 6) (#1637) 2026-10-30 16:45:33 +07:00
bflatastic 1aab3fa001 M6: the missing-image placeholder's stripes stay clean when scaled, and a .fig gives one note (#1646) 2026-10-30 16:40:24 +07:00
BFlat 84e5278209 M6: figcraft-cli connect publish publishes code connect mappings to a design file (#1643)
* M6: figcraft-cli connect publish publishes code connect mappings to a design file

figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run] links each entry's component or set by name through codeConnect.import, warns about and skips links that don't fit, saves the file in place through the engine's file.save, and exits 1 when nothing links. Gap 16.

* M6: the generated man pages include connect and connect publish
2026-10-30 16:31:25 +07:00
bflatastic db41f2cd37 M6: a pasted image the clipboard doesn't carry shows as missing and is named (#1646)
The placeholder an import stores for an image it lacks is now striped, not flat grey; the fill keeps the image's name; .fig and paste give a fixed, translated note per image; edit.pasteDesignLayers returns missingImages, the paste's message names those layers, and a paste with notes opens the import report.
2026-10-30 16:20:39 +07:00
BFlat 10c66678c3 Merge pull request #1641 from ChristopherLinnett/feat/1280-color-calculations
M3: colour variables can be calculated from others, faded or mixed per mode (#1280)
2026-10-30 16:16:23 +07:00
BFlat 99440fa9ff M8: a drag that only moves the selection shifts what was worked out at its start (#1571) (#1640)
The engine notes when a drag has only moved the selected layers by one offset (Interaction::moved_by); the canvas keeps the outlines, box and slots from the drag's start document and shifts them, the selection spacing and the Design panel's values (but the position fields) come from that document, and the Export section's values are kept. Dragging 648 layers: UI pass 2.10 to 1.44 ms a frame.
2026-10-30 16:13:55 +07:00
Christopher Linnett 8d5bad2afc M3: CSS export never names a calculation it leaves out either (#1280)
The CSS export left out a colour calculation that resolves to nothing but still wrote an alias to
it as var(--name); where no block defines that property, whatever reads the alias is invalid.
CSS (and Tailwind) and Penpot now share one rule, Variables::flattener: an alias that leads to a
calculation some mode of its collection leaves out goes out as the value it shows in the mode,
or is left out when it shows nothing.
2026-10-30 16:59:19 +08:00
BFlat 34fce9bfcf M12: create the next board shape already connected (#1631)
* M12: create the next board shape already connected (#1597)

A selected sticky or shape shows a + beside each side; hovering one previews where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector, and starts typing in it, in one undo step. board.addConnected {id?, side?, edit?} does it from commands.

* M12: the connected-shape handle and command in every full catalog (#1597)

* M12: Shift+Cmd/Ctrl+Enter on a board shape adds the next one already connected (#1597)
2026-10-30 15:59:13 +07:00
bflatastic 68450a6a0e Merge main into #1641 2026-10-30 15:57:31 +07:00
BFlat d17de284da Tests: read the docs' examples whatever the checkout's line endings (#1645)
inspect_fields::compact_examples_run_verbatim_from_the_docs split docs/mcp.md and docs/control-protocol.md on text ending in a newline, so on a Windows checkout with core.autocrlf (CRLF docs) it panicked and cargo xtask ci failed locally. The test now reads both with LF line ends. Found while gating on Windows.
2026-10-30 15:53:33 +07:00
BFlat 74b69125b7 Merge pull request #1642 from kler/swedish-ui-2026-10-30
i18n(sv): translate 36 new strings
2026-10-30 15:50:39 +07:00
BFlat c1451fecd5 M4: an imported file finds an installed family under the other names its fonts give it (#1292) (#1628)
Fonts name a family by its typographic name and by a legacy name per weight; fonts without typographic names are installed under the legacy ones only. FontDb::resolve finds either from the other, and matches names across case, spaces, hyphens and underscores; every lookup (import, paste, REST import, rendering, missing fonts, the font report) uses it. A weight-number style goes by that weight, and .fig and REST imports wait for the installed fonts to be indexed.
2026-10-30 15:44:09 +07:00
BFlat 328bb6e269 Merge pull request #1609 from avhn/fix/1085-grouped-panic-rollback
M8: a grouped command whose closure panics leaves the document as before (#1085)
2026-10-30 15:27:30 +07:00
Christopher Linnett 55e5f31eb5 M3: document the colour-calculation functions this change touches (#1280)
Why type_of gives references no type, what display shows, how resolve_memo bounds one resolve,
and what variable.create and variable.set check before anything changes.
2026-10-30 16:25:26 +08:00
Christopher Linnett 1784567497 M3: Penpot tokens never reference a calculation a set leaves out (#1280)
A colour calculation that resolves to nothing in a mode is left out of that mode's token set, but
an alias to it still went out as a {name} reference to the missing token. Such an alias, and any
alias leading to one, now goes out as the colour it shows in the mode, or is left out with the
calculation's warning when it shows nothing. A calculation left out in only one mode of its
collection counts too: a reference to it from another collection would dangle whenever that
mode's theme is active.
2026-10-30 16:25:26 +08:00
Christopher Linnett bc76665c65 M3: colour variables can be calculated from others, faded or mixed per mode (#1280)
A colour variable's value in a mode can now be {"alpha": {"variable", "opacity"}} - that
variable's colour with its alpha replaced - or {"mix": {"from", "to", "t"}}, a per-channel blend
of two colour variables or literals over the stored values (in the document's colour profile),
alpha included. Calculations resolve lazily like aliases, so bound layers, mode switches,
instances and undo follow their sources; they compose, and a loop, or a calculation that reads
more than 32 variables in its collection's modes (other collections in their default modes), is
refused when it is set. Operands are stored by id and read by exact id, as stored references
are. Files keep them; REST, CSS, Penpot and .fig exports write the colour they show in each mode;
deleting a source leaves its readers on the colour they showed; library import and swap
re-point their operands. The Variables panel shows them with their formula and can swap one for
an alias or detach it to its colour.
2026-10-30 16:25:26 +08:00
BFlat 1772ecefb0 Merge pull request #1636 from ChristopherLinnett/fix/574-fig-import-large
M8: .fig import budgets four values per compressed canvas byte instead of one (#574)
2026-10-30 15:22:58 +07:00
Patrik LermonandClaude Opus 5.5 8dd69797af i18n(sv): translate 36 new strings
Brings the Swedish catalog up to date with upstream main (e94350a7).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:19:45 +01:00
bflatastic 955f101221 Merge main into #1609 2026-10-30 15:12:01 +07:00
BFlat e110f23d8c Merge pull request #1615 from avhn/fix/1396-cli-completions-man-pages
M0.9: generate shell completions and man pages for figcraft-cli (part of #1396)
2026-10-30 15:09:34 +07:00
BFlat e94350a778 Merge pull request #1630 from samhyun/perf/partial-damage-margin
M8: an edit renders where the changed layer paints, its effects' margin once
2026-10-30 15:07:13 +07:00
bflatastic 027a0bbf92 Merge main into #1636 2026-10-30 15:06:33 +07:00
BFlat c44f382d71 Merge pull request #1633 from samhyun/fix/partial-backdrop-at-view-edge
M8: a patch reads no backdrop past the view's edge
2026-10-30 15:05:08 +07:00
bflatastic 7c8f2a929a Merge main into #1609 2026-10-30 14:52:57 +07:00
bflatastic 4acbb1f7db Merge main into #1615 2026-10-30 14:52:23 +07:00
BFlat cb5f047fbd Merge pull request #1616 from avhn/fix/1076-fig-image-budget
M8: .fig ZIP image reads share a total byte budget (part of #1076)
2026-10-30 14:50:02 +07:00
bflatastic e5a5da9e62 Merge main into #1609 2026-10-30 14:35:19 +07:00
bflatastic 83c6947e23 Merge main into #1615 2026-10-30 14:34:35 +07:00
bflatastic d9955fdc14 Merge main into #1616 2026-10-30 14:32:50 +07:00
BFlat 51f275b2ff Merge pull request #1420 from avhn/feat/996-inspect-fields
M0.9: inspect layer trees with selected fields or a compact outline (#996)
2026-10-30 14:31:33 +07:00
bflatastic 871ac24e6f Merge main into #1616 2026-10-30 14:29:56 +07:00
bflatastic 09e0e2dbfc Merge main into #1609 2026-10-30 14:29:18 +07:00
bflatastic 015685ee6f Merge main into #1615 2026-10-30 14:20:35 +07:00
BFlat c8b0726d2d Merge pull request #1505 from markx77/fix/german-labels
M10: clarify German gradient, OpenType and startup labels
2026-10-30 14:18:18 +07:00
bflatastic e573652b17 Merge main into #1420 2026-10-30 14:14:34 +07:00
bflatastic ed5bf558c4 Merge main into #1615 2026-10-30 14:13:56 +07:00
BFlat ca703a03a7 Merge pull request #1618 from avhn/fix/1613-stored-variable-ids
M3: stored variable and collection references keep their identity (#1613)
2026-10-30 14:11:39 +07:00
Christopher Linnett 1a454edd09 M8: .fig import budgets four values per compressed canvas byte instead of one (#574)
The .fig import gave a file one decoded value per byte of its compressed canvas (at least 20M, at
most 128M). Dense legitimate files decode past that, further since every scalar field counts, and
the whole import was refused as "not a valid FigCraft file: .fig file: message: too many values".
The budget is now four values per compressed byte; the floor and both ceilings (128M, 32M on 32-bit
targets) are unchanged, so a small file that unpacks past the floor is still refused and no file
decodes more values than the ceiling allowed before. Generated 21 MB and 15 MB canvases (220,044
and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole
without warnings; the 49.7 MB one's test peaks at about 9 GB.
2026-10-30 15:09:50 +08:00
bflatastic 58d791c887 #1616: take the image byte budget from IMAGE_BUDGET 2026-10-30 13:57:08 +07:00
bflatastic d0d79fdee5 Merge main into #1616 2026-10-30 13:57:06 +07:00
bflatastic 991ea891b5 Merge main into #1609 2026-10-30 13:56:15 +07:00
bflatastic c334853a6e Merge main into #1505 2026-10-30 13:53:42 +07:00
bflatastic 966f823551 Merge main into #1420 2026-10-30 13:52:53 +07:00
bflatastic 1c9aece79d Merge main into #1618 2026-10-30 13:52:02 +07:00
bflatastic 10a778930b Merge main into #1615 2026-10-30 13:50:49 +07:00
BFlat c47177498d M8: every file a command reads by path is a regular file within a cap (#1114) (#1625) 2026-10-30 13:48:26 +07:00
Dylan M. Taylor b0717ba627 UPD.1: honor app pretend versions in update checks (#1627) 2026-10-30 02:45:41 -04:00
bflatastic 20ae99afff Merge main into #1505 2026-10-30 13:36:51 +07:00
bflatastic cf54d64a64 Merge main into #1420 2026-10-30 13:36:11 +07:00
bflatastic ad8bad3cc3 Merge main into #1618 2026-10-30 13:35:24 +07:00
bflatastic eafa29700a Merge main into #1615 2026-10-30 13:34:41 +07:00
BFlat 47e9e0a50d M8: a component set's property rows fit the 240 px right panel (#1629) 2026-10-30 13:32:36 +07:00
samhyunandClaude Opus 5.5 40f83a9a08 M8: a patch reads no backdrop past the view's edge
Background blurs and glass read the pixels drawn under them, and a full
render's backdrops end where the view does. The retained renderer differed
in two ways.

render_into drew each damage rect with a margin all round, past the edge
too, so a backdrop there read content a full render never drew: with a
background blur of 60 across the left edge, moving a square beside it
patched 10 levels off a full render, and moving one entirely off the view
89. The margin now stops at the edge.

A pan re-rendered only the strips it uncovered. At the opposite edges, which
moved into the old frame, the shifted pixels kept what their backdrop had
read past the old edge: a background blur of 10 across the view was 89
levels off after a pan of 30. Those edges are now redrawn as far as a
backdrop reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:26:36 +09:00
bflatastic a3c0de04a8 Merge main into #1505 2026-10-30 13:22:50 +07:00
bflatastic cf129e42c9 Merge main into #1420 2026-10-30 13:21:51 +07:00
bflatastic a09e23e1d2 Merge main into #1618 2026-10-30 13:20:42 +07:00
bflatastic 9ae8c2e377 Merge main into #1615 2026-10-30 13:20:02 +07:00
BFlat 3bdbe5b2a0 Merge pull request #1614 from avhn/fix/1130-operator-key-hash
M9: the server compares the operator key by its hash (#1130)
2026-10-30 13:18:06 +07:00
samhyunandClaude Opus 5.5 a3d70d4c43 M8: an edit renders where the changed layer paints, its effects' margin once
paint_bounds, the area a change to a layer can affect, was its paint bounds
(effects included) grown again by effect_margin, what its effects read from
outside. Drawing an area already takes that in, as culling does, so the
damage of an edit near effects was counted twice: moving a 120 x 120
rectangle with a shadow falling 40 rendered 122,844 pixels for 56,876 it can
change, and edits that would fit a partial render fell back to full ones.

The retained test that a group's shadow had pushed past the partial share
gets its 300 x 220 view back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:10:49 +09:00
bflatastic 6e4450d209 Merge main into #1615 2026-10-30 13:07:00 +07:00
bflatastic b157a38e99 Merge main into #1618 2026-10-30 13:05:24 +07:00
bflatastic cabbb138da Merge main into #1614 2026-10-30 13:04:48 +07:00
BFlat 7b886404c3 M8: Opening a file contacts no team library server it names until you check it (#1621)
* M8: opening a file contacts no team library server it names until you check it (#1097)

* M8: translate the team library check strings (#1097)

* M8: the Assets panel library test checks what allows a library, without its own fake link (Sonar duplication and coverage)
2026-10-30 13:03:31 +07:00
bflatastic f11cad01b8 Merge main into #1615 2026-10-30 13:03:09 +07:00
BFlat 15479eacb8 Merge pull request #1626 from samhyun/fix/scaled-effect-reach
M8: effects reach as far under a scaled layer
2026-10-30 13:01:40 +07:00
bflatastic 67540b0197 Merge main into #1505 2026-10-30 12:51:34 +07:00
BFlat f1eaf3fcb4 M8: cap the people a client keeps track of and the deleted ids a session remembers (#1622)
* M8: cap the people a client keeps track of and the deleted ids a session remembers (#1121)

A hostile host or server could grow a client's presence, seen-peers and who-is-here lists without bound, and a session's deleted-id set only grew. Clients keep at most MAX_PEERS (4,096) others, leaving newcomers out with a warning; max_people is at most that; a session remembers at most 262,144 deleted ids, the lowest forgotten first.

* M8: a max_people above what clients track is lowered with a warning, not refused (#1121)

Refusing it would stop a server whose existing config says more from starting after an upgrade. Config::people() lowers max_people to MAX_PEERS for the core's limits, and the startup warnings name the configured and the used value.

* M8: the live welcome's handling in its own function (Sonar S3776)
2026-10-30 12:33:29 +07:00
Dylan M. Taylor 0b7528b76d Update prompt shows the app icon and accent color instead of a letter (#1612)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#02da96, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:14 -04:00
Markus Reimr 7996de560c M10: merge current main and resolve German PR conflicts 2026-10-30 06:24:55 +01:00
BFlat b2885452fb M8: doc.list gives AI agents and plugins file names, not paths (#1117) (#1620) 2026-10-30 12:14:34 +07:00
samhyunandClaude Opus 5.5 07c936f560 M8: PR #1626 review: tiny scales and pixel-spread layers
local_reach gave up on transforms whose determinant was under 1e-12 and
kept the reach in page units, so a layer scaled to a ten millionth (a
billion units drawn 100 wide) had its shadow cut (245 levels). It now
converts any finite, non-zero scale, capping the result where the sums
would overflow.

Groups and stroked text spread their shadows as pixels, in page units:
their spread no longer also counts in local units.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:12:58 +09:00
BFlat 5247f73f59 M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1611)
* M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1571)

The selection's outlines, box, size and slots on the canvas, and the Design panel's instance checks and position values, are kept with the document snapshot (held weakly), the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any changes. Dragging 648 selected layers: overlays 1.99 to 0.86 ms a frame, Design panel 2.54 to 1.40 ms.

* M8: split the position fields into one function per row (Sonar S3776)
2026-10-30 11:54:44 +07:00
BFlat a5f88f146c Docs: retake the README screenshots on current main (#1623) 2026-10-30 11:45:53 +07:00
BFlat 7d18fa391b M8: libraries and plugins larger than the download burst can be downloaded, more slowly (#1089) (#1619)
The per-address download bucket never held more than its 16 MiB burst, so a larger library or plugin was refused for good. Bucket::take_spread serves one from a full bucket and leaves the debt to refill at the rate before the next download; the rate over time still holds.
2026-10-30 11:39:51 +07:00
BFlat 24cf643254 M8: a batch keeps one undo snapshot, not one per step (#1099) (#1610) 2026-10-30 11:22:06 +07:00
cc0976a4a2 #1609: a grouped panic keeps its payload and lets later live edits sync
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-10-30 07:13:03 +03:00
87e4abfbf6 #1615: each generated-docs test gets its own temporary directory, removed when it ends
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-10-30 07:11:15 +03:00
Mert Dede 1c4201f48a Merge remote-tracking branch 'origin/main' into fix/1613-stored-variable-ids
# Conflicts:
#	docs/roadmap.md
2026-10-30 07:08:06 +03:00
Mert Dede 415e83814f Merge remote-tracking branch 'origin/main' into fix/1076-fig-image-budget
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
#	docs/roadmap.md
2026-10-30 07:08:02 +03:00
Mert Dede fa3c21c3c5 Merge remote-tracking branch 'origin/main' into fix/1396-cli-completions-man-pages
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-30 07:08:00 +03:00
Mert Dede 45977263c7 Merge remote-tracking branch 'origin/main' into fix/1130-operator-key-hash
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-30 07:07:58 +03:00
Mert Dede 3a59a70b10 Merge remote-tracking branch 'origin/main' into fix/1085-grouped-panic-rollback
# Conflicts:
#	docs/roadmap.md
2026-10-30 07:07:56 +03:00
Mert Dede 5c3aef840a Merge remote-tracking branch 'origin/main' into feat/996-inspect-fields
# Conflicts:
#	docs/roadmap.md
2026-10-30 07:07:54 +03:00
BFlat 4224f4b660 M8: deep files import on the browser's 1 MB stack (#1101) (#1600)
The REST import's layer walk (also the .fig import's second stage) took about 4 KB of stack a level, a megabyte at the 256-level cap, and overflowed a 1 MB thread. It now runs on a work list in the same order, fitting groups once their children are in. The .fig mapping stays recursive at about 1.8 KB a level (455 KB at the cap). Both 256-deep imports now run on a 1 MB stack; the cap stays 256 everywhere.
2026-10-30 11:04:57 +07:00
2f47014917 M3: stored variable and collection references keep their identity (#1613)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-10-30 06:49:06 +03:00
fcde83a5a6 M8: .fig ZIP image reads share a total byte budget (part of #1076)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-10-30 06:49:05 +03:00
05a475dccf M0.9: generate shell completions and man pages for figcraft-cli (part of #1396)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-10-30 06:49:05 +03:00
8da99e5374 M9: the server compares the operator key by its hash (#1130)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-10-30 06:49:05 +03:00
BFlat 7fdbda9f09 M8: One depth cap for a whole render, and render threads with room for it (#1605)
* M8: one depth cap for a whole render; render threads get 8 MiB stacks (#1082)

A pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels. The source now goes on from the depth of the layer it fills. A render level takes about 6.5 KB of stack (measured), about 1.7 MB at the cap: the canvas render, preview and save threads and a band pool of their own now get 8 MiB stacks instead of the default 2 MiB.

* M8: SVG and PDF exports carry the depth into pattern sources and embedded renders (#1082)
2026-10-30 10:43:49 +07:00
BFlat 826191559a Merge pull request #1608 from avhn/fix/1114-variables-file-cap
M8: an explicit variables file is refused past 256 MiB or when it isn't a regular file (part of #1114)
2026-10-30 10:25:14 +07:00
bflatastic e33388d4a6 Merge main into #1608 2026-10-30 10:12:24 +07:00
BFlat fb0465f325 M8: a server file everyone left stays loaded a minute and its open time is written hourly, so idle join/leave does no disk I/O (#1074) (#1606) 2026-10-30 10:10:46 +07:00
samhyunandClaude Opus 5.5 0df9189750 M8: effects reach as far under a scaled layer
The renderer draws drop shadows, layer blurs and texture at their size in
page units however a layer is transformed, but sized a layer from its local
box grown by the effects' reach in local units, so under a transform that
scales down the effect was cut (245 levels on the canvas, exports holding
half to 60% of the paint).

bounds_within now takes the layer's local-to-page map and turns the reach
into local units per axis. A drop shadow's spread of a filled outline is the
exception: the outline is grown before it is transformed, so that part
follows the layer's scale. Layer bounds and own_paint_bounds take, per
shadow, the larger of the two readings, so an unscaled layer comes out as
before; exports of scaled-up spread shadows, which were cut, now hold them,
and so do the corners of a turned rectangle's spread. The mitre reach is
clamped like the other reaches, so a huge spread no longer makes bounds NaN.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 12:03:28 +09:00
bflatastic e79afdce67 Merge main into #1608 2026-10-30 09:57:22 +07:00
9573b8b2d2 M8: a grouped command whose closure panics leaves the document as before (#1085)
A panic in a Session::grouped closure outside any command skipped the group rollback: the
documents stayed half edited, the history limit unbounded, live documents held and the group
depth raised. checkpointed now catches the unwind, runs the same rollback as a failed group and
resumes the panic, so the caller's guard still reports it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-30 05:54:31 +03:00
BFlat e274279587 M8: one unpack budget per .figcraft open (#1079) (#1599)
Each area (document, images, library, versions, branches) keeps its own cap and now also draws from one total per file open, 6 GiB (2 GiB on 32-bit targets), so a file can no longer make the app unpack about 10 GB. Read in that order, the document and the images up to their own cap fit, so a file whose images pass 4 GiB (#1313) keeps as many as before; images left out by the total stay in the file like those past the image cap. What passes the total is skipped (the document refused) with a note.
2026-10-30 09:44:47 +07:00
9093afac10 M8: an explicit variables file is refused past 256 MiB or when it isn't a regular file (part of #1114)
variable.import and the variables parameter of file.open and file.import read an explicit path
with the uncapped reader, while the sibling variables.json next to a REST file already went
through read_capped with MAX_VARIABLES_FILE. variables_param now uses that capped read for paths
on native builds; the browser keeps its no-file-system error. Inline objects and JSON text, and
access roots, are unchanged. The check reads the size before the file, so it does not bound a
file that grows or is replaced in between. Primary file reads and image.place stay uncapped.
variable.import's help and docs/mcp.md name the limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-30 05:31:20 +03:00
BFlat 38a4feccc9 M8: hover hit-tests once per pointer move, and a moved auto layout frame keeps its layout (#1571) (#1601)
Every pointer move draws about three frames, and the canvas hit-tested the hover on each: a hover where nothing changed since the last (place, modifiers, zoom, file, page, revision, selection, tool and modes) is no longer sent to the tools again. Dragging an auto layout frame laid its contents out again on every move: a frame whose only change is its transform now keeps its own layout (its parent's still runs). ui.inspect reports frameTimes. Measured on a synthetic 1,900-layer file: hover UI pass 0.91 to 0.49 ms a frame and CPU 33% to 22% of a core; normalise while dragging 648 layers 3.8 to 2.1 ms a move.
2026-10-30 09:28:34 +07:00
BFlat 330965f5c1 Merge pull request #1603 from samhyun/fix/group-effect-layer-bounds
M8: a group's effects reach past what its children paint
2026-10-30 09:19:31 +07:00
Dylan M. Taylor 319518a402 CI: workflows only call craft-common-actions at @main (#1587)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:17:20 -04:00
BFlatandBrandon Thomas 675777e075 M8: .fig instance expansion without quadratic walks and copies (#1077) (#1594)
Variable and collection nodes are left out of the layer tree (variables are read from the node list), so an instance's expansion no longer walks them again for every instance outside the layer cap. A nested instance takes only the override rows addressed below it, found by range, and shares its outer instances' property values and slot contents instead of copying them. Crafted files: 13.8 s -> 21 ms and 40.5 s -> 98 ms; normal files map as before.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-29 22:01:46 -04:00
34ba937da8 M9: Comments: mentioned names in comment.people, and mentions in comment.next and comment.prompt (#1591)
* M9: comment.list lists only the threads that mention a name (#1415)

comment.list takes mentions (a name, any case): only threads where a message, the first or a reply, @mentions it, by the same parser the thread view highlights with and each message's mentions reports; combined with the other filters.

* M9: comment.people lists mentioned names; comment.next and comment.prompt carry mentions (#1415)

comment.people adds every name a message @mentions to those who wrote or reacted (once each in any case). comment.next takes the mentions filter and returns the thread as comment.list lists it; comment.prompt's bounded comments carry each thread's mentions (at most 32, names cut to 80 characters). One mention helper serves them all; builds on #1585.

* M9: comment.people lists people who wrote in two cases once

The participant list is deduplicated in any case too, not only the names added from mentions, in
one pass (CodeRabbit on #1591).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 22:00:50 -04:00
393abf8075 M8: Grid auto-placement on an occupancy bitmap (#1593)
* M8: grid auto-placement on an occupancy bitmap (#1083)

Grid placement tested every cell of a child's span at every position it tried. The taken cells are now a bitmap: spans are checked a 64-bit word at a time, the search jumps past the blocking cell, a row's search ORs the span's rows once, and a column-anchored child looks on from where the last one with the same column and spans found room. Placements are unchanged (checked against the old search on random grids); a crafted 1000 x 1000 grid takes 0.3 ms instead of 192 ms.

* M8: grid occupancy stores rows only once a cell in them is taken

An empty or sparse grid no longer allocates the whole bitmap up front (CodeRabbit on #1593).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-30 08:56:43 +07:00
samhyunandClaude Opus 5.5 81f89b314b M8: a group's effects reach past what its children paint
A group or an unclipped frame applies its layer blur, drop shadows and
texture to everything its children paint, including children reaching past
its box. The renderer sized the container's layer from the union of its
children without growing it by the container's own effects, so the blur or
shadow was cut where a child's paint ended; exports of unclipped frames
lost part of the shadow the same way.

bounds_within now grows the children's union by the node's effects reach,
and render_bounds_at grows an unclipped frame's children by its effects.
The retained partial-render test drew this very cut (61 levels in its group
child scene); with the shadow counted the damage passes the partial share
of its 300 x 220 view, so the view is widened to keep it a partial render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:51:57 +09:00
BFlat bc11197c21 M1: Enter or a double-click with the pen edits the vector with the Move tool (#1430) (#1590)
A double-click with the pen drew on: its second press added a point on the last one, and a double-click meant to edit a layer drew a stray vector on it. A press on the last anchor now adds nothing; a double-click ends the path (or, when it only started one, edits the layer under it) and Enter ends the path, both going on in vector edit mode with the Move tool. In edit mode the pen's Enter and double-click take the Move tool, the double-click taking back what its first press began. Escape still keeps the pen (#760).
2026-10-30 08:41:01 +07:00
BFlat 706d663b01 Merge pull request #1598 from samhyun/fix/svg-stacked-layer-blurs
M8: the SVG export chains layer blurs and casts shadows from the blurred layer
2026-10-30 08:38:14 +07:00
BFlat 019058ffd5 M1: a recovery copy made during a version preview holds the file, not the version shown (#1588)
Desktop recovery copies saved DocState::doc, which is the version shown while a version (or a live text preview) is previewed, so a crash during a preview brought the old version back as the unsaved file. What a recovery copy holds is now one shared helper, branches::recovery_copy: the document itself (document_itself(), the same document the unsaved-changes check compares), its history and its branches. The desktop copies and the browser's (#1580) both use it.
2026-10-30 08:21:47 +07:00
samhyunandClaude Opus 5.5 97f502cb42 M8: the SVG export chains layer blurs and casts shadows from the blurred layer
The canvas applies layer blurs one after another and casts drop shadows from the layer they leave, cut out by it. The SVG filter blurred SourceGraphic once per blur into the same result, so only the last was drawn, and its shadows came from the unblurred alpha: blurs of 12 and 16 with a shadow came back from an export 73 levels off. The blurs now chain, the shadows are cast from the last blur's alpha and cut out by it, and the import reads that back as one blur as strong as the stack with the same shadows. Coloured copies of a blurred layer are still reported, not read as black shadows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:20:30 +09:00
BFlat 8ee8ba6ce7 M6: FigJam .jam files open, as boards (#1342) (#1586)
* M6: FigJam .jam files open like .fig files (#1342)

A .jam is the same archive as a .fig (its canvas header reads fig-jam.), which the .fig reader already took; File > Open and its filter, drag and drop, file.import and the command line now take .jam too, and the home screen shows its thumbnail. FigJam layers come in as the .fig reader makes them (stickies and shapes with text as frames with their generated layers, other FigJam layers as frames or vectors with a note, unknown types left out with a note). Tests use a synthetic fig-jam. canvas and a .jam made from our own export.

* M12: a FigJam .jam file opens as a board (#1342)

The .fig reader marks a canvas with FigJam's fig-jam. header as FileKind::Board, so a .jam opens with the board toolbar, the dot grid and the board panels and saves as a board; a .fig stays a design file, and importing a .jam into an open file leaves its kind alone. Its objects stay ordinary layers (frames and vectors with the existing import notes). Builds on the .jam opening branch (#1586).
2026-10-30 08:03:56 +07:00
BFlat e2adcd92c7 Merge pull request #1577 from YarilaFox/fix/frame-resize-row-icon
M1: compact Frame actions and dev status in the header
2026-10-30 07:55:26 +07:00
BFlat b2b9e08a46 Merge pull request #1589 from samhyun/fix/export-stacked-blur-bounds
M8: exports hold what stacked layer effects paint together
2026-10-30 07:53:10 +07:00
BFlat 7ed4e7cd61 Merge pull request #1583 from samhyun/fix/sequential-layer-blur-margin
M8: stacked layer blurs keep the margin they reach together
2026-10-30 07:50:55 +07:00
BFlat d29f544707 Merge pull request #1579 from Silvertree2010/fix/h3968-title-case-capitalises-mid-word
Keep combining marks inside the word in title case
2026-10-30 07:48:32 +07:00
BFlat c382241660 M8: the Actions palette offers only commands that run from it (#1574) (#1582)
A search offered engine commands that need parameters the palette can't give, such as image.place (Place image, needing data or path), which failed with Invalid parameters and sat above the menu's Place image... row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without ? (figcraft_engine::cmd::needs_params, worked out once per command), and a row no menu item gives whose label reads like a menu item's. Place image runs the menu item, which opens the file dialog.
2026-10-30 07:48:00 +07:00
BFlat e7ae0f4426 M9: share links can comment: a comment access level beside edit and view (#1525) (#1576)
Someone who joins with a comment link (or its join code) sees the file live and adds, replies to, reacts to and resolves comment threads, but can't change the design: their app refuses other edits and the server accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones; the Share popover offers Can edit, Can comment and Can view, and the commenter's toolbar shows a Can comment badge with Move, Hand and Comment.

Backward compatible: clients announce the comments word; one that doesn't is welcomed with a comment link as a viewer and gets comment links listed as view links, a level this build doesn't know reads as view, and an older server that can't make a comment link leaves the share without one.
2026-10-30 07:29:40 +07:00
samhyunandClaude Opus 5.5 872c85bf7d M8: review on #1583: the too-large margin test asserts the capped sum
The test only checked for a large finite margin, which the old largest-effect margin (1e308) passed as well. It now asserts the two capped reaches added up, so it fails on a return to either the old maximum or an overflowing sum.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:13:26 +09:00
BFlat a09821f942 M1: Preferences > Scroll wheel zoom (#1390) (#1584)
With pref.scrollWheelZoom on, a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpads, pinches and touch screens are unchanged. A wheel is told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps.
2026-10-30 07:12:46 +07:00
samhyunandClaude Opus 5.5 9426eff1ff M8: exports hold what stacked layer effects paint together
Layer blurs and texture apply one after another and drop shadows are cast from what they leave, but an export grew the node's box by each effect on its own. Stacked blurs came out cut at the image's edges (eight blurs of 10: alpha 10 at the edge), and a sharp shadow under a blurred layer lost the blur around it (alpha 126). Blurs now combine as one whose squared reach is the sum of theirs, texture adds on, shadows grow from that, and paint_reach follows the same rule. Reaches are clamped so adding them stays finite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:10:14 +09:00
BFlat bc6dad0c2f M9: comment.list lists only the threads that mention a name (#1415) (#1585)
comment.list takes mentions (a name, any case): only threads where a message, the first or a reply, @mentions it, by the same parser the thread view highlights with and each message's mentions reports; combined with the other filters.
2026-10-30 06:56:33 +07:00
BFlat 974ab39503 M8: keep unsaved files when a panic stops the browser build (#1123) (#1580)
wasm32 can't catch a panic, so the command and frame guards do nothing in the browser and a panic ended the page with its unsaved changes. The page now follows its files with unsaved changes after every frame; its panic hook writes them to local storage as .figcraft copies (as many as fit) and says on the page that it stopped. The next page opens them again as unsaved files: file.recover also takes the copy as base64 data. The recovery-copy helpers move from the desktop app into figcraft_engine::branches so both share them.
2026-10-30 06:36:33 +07:00
BFlat cc3052de5e M8: cap the input of boolean operations (#1104) (#1581)
Each sweep takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted in O(n log n)), and a fold at most 4 million segments swept over its steps; past them the operation is refused with PathOpsError::TooComplex before it runs. A boolean group past the caps draws without its result, with a warning once, and the refusal is cached; vector.flatten refuses it instead of dropping it.
2026-10-30 06:03:35 +07:00
Mert DedeandClaude Opus 5.5 5f4e22ee70 Merge main into #1420
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:48:09 +03:00
Mert DedeandClaude Opus 5.5 42e1a39d72 Merge main into #1420
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:47:34 +03:00
BFlat be7a2ecd3c M0.9: parse figcraft-cli arguments with clap, following RFC-0012 (#1396) (#1570)
clap derive types replace the hand-written parser: typed values, usage
errors (exit 2) before any work, help from the same definitions.

Syntax changes: a repeated option is a usage error (was: last wins; the
automation roots still repeat); a value starting with `-` needs `=`; a
positional starting with `-` needs `--`; `--o` and the `version`
subcommand are gone (`--version` stays); `<sub> --help` prints that
subcommand's help; usage errors are clap's text; `--format` is checked
before the file is opened.
2026-10-30 05:41:26 +07:00
BFlat 6f50d83083 M8: the browser worker's preview and wait tests run on a clock and a threshold they set (#1575)
The browser worker's tests timed the wait after a preview with real sleeps and assumed two polls in a row land within the last full render's time, that a scene tuned to render slowly still renders slower than the preview threshold when the test runs, and that the first image after a request is its last. On a busy machine (the full suite) a test thread can be held off the CPU between polls for longer than the wait, a scene tuned while the machine was busy can render quickly once it isn't, and fonts another test loads mid-render make the worker show that render and draw it again.

Inline::poll_at takes the time (poll passes now) and, in tests, Inline::with_threshold sets when renders get a preview. The four wait tests poll at times they choose on a plain page that previews every render from scratch, the measured-time test previews once renders have taken any time at all, and the refined helpers wait for the last image of a request: no sleeps, no dependence on render speed or on other tests. The capped test now also checks the preview is not refined just before the last full render's time.
2026-10-30 05:29:49 +07:00
NetterSilverandClaude Haiku 5.5 bf97557eb2 Keep combining marks inside the word in title case
Found while testing crates/doc/src/text.rs:150 (TextCase::apply_impl, Title arm) with is_word_char at :156

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 22:28:51 +00:00
BFlat 47552c00a8 Merge pull request #1559 from Silvertree2010/fix/h3976-parse-css-accepts-non-finite-hsl
Reject non-finite hsl()/hsla() components in parse_css
2026-10-30 05:18:48 +07:00
YarilaFox 1fabb9aa53 M1: move frame actions and dev status into header 2026-10-30 03:16:40 +05:00
BFlat 8811a23b6f M7: selection and hover outlines follow rounded corners (#1506) (#1573) 2026-10-30 05:07:43 +07:00
BFlat 7d76b0c673 M3: component properties keep their order on rename; Make slot asks for the name (#1536, #1537) (#1565)
Properties were listed by name, so a rename moved one. Components now keep prop_order (the names as they were made; a rename keeps the place) and every listing follows it. Make slot opens a name field under the frame's Slot row; Enter makes the slot, Escape cancels, a taken name keeps the field open.
2026-10-30 04:49:00 +07:00
BFlat 3c1718bf86 M9: publish to a server from the browser (Share > Online and Publish to server in the web build, #917) (#1567)
The web transport implements LiveLink::publish and the hosting and sharing background requests over the page's WebSocket. Managing connections run as exchanges (exchange.rs) in the browser client's driver; the requests and what their answers mean are shared with the desktop transport (requests.rs). The Share popover offers online sharing in the browser (no local network), and Publish to server opens there. Fixes #917.
2026-10-30 04:32:48 +07:00
BFlat 704fb0b717 M4: use the fonts installed on this computer in the browser (Local Font Access, #1051) (#1568)
Where the browser lists them (window.queryLocalFonts, Chromium), the font menu offers Use fonts installed on this computer; the fonts join the font database by name and each style's bytes are read only when text uses it, then auto-sizing text refits. Rust only (js-sys/web-sys). Fixes #1051.
2026-10-30 04:14:33 +07:00
BFlat d890de92c4 M8: SVG import reads color() and color-mix() colours (#1550) (#1566)
The SVG parser reads neither, so fills came in black and strokes were dropped without a note. They are resolved before parsing: color(srgb|display-p3 ...) converted to the document's profile, color-mix(in srgb|oklab|oklch, ...) evaluated as CSS Color 5 defines it. Other spaces are approximated with a note; unreadable calls become a mid grey with a note. Bounded length, nesting and count.
2026-10-30 04:01:07 +07:00
samhyunandClaude Opus 5.5 be9223a9ed M8: stacked layer blurs keep the margin they reach together
Layer blurs and texture apply one after another, so together they reach further than any one of them, and drop shadows are cast from the blurred layer. effect_margin took the largest single effect, so a layer cut at a view's or band's edge missed content that still reached in: four blurs of 25 drawn in 64-row bands differed from a render in one piece by 8 levels, and a blurred rectangle above the view cast its shadow into it 143 levels off. The margin is now the layer effects' reaches added up, plus the largest of the others; each reach counts up to 1e12 so the sum stays finite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:58:28 +09:00
BFlat aa727500a4 M1: keep settings next to the executable in portable mode (#1320) (#1558)
The settings folder comes from craft-settings' rules: FIGCRAFT_CONFIG_DIR,
a `portable` file next to the executable (config/ beside it), then the
platform folder every earlier version used. The Windows portable zip ships
the marker, and a portable folder without preferences starts with a copy
of the platform folder's.
2026-10-30 03:35:12 +07:00
BFlat 28a0fe6d18 M9: the admin budget test allows for the bucket refilling during a slow run (#1564) 2026-10-30 03:23:11 +07:00
BFlat 05f811105c M1: start on other graphics when the window's first ones fail (#965, #1438) (#1557)
Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-call
validation shaders, the two causes of "Parent device is lost" at start.
When the graphics still fail before the first frame, the app starts again
with the next of a fixed list of attempts (Windows: Vulkan, OpenGL; Linux
and FreeBSD: OpenGL; then lower requirements on the power-saving GPU), and
the last failure's message names what was tried.
2026-10-30 03:04:11 +07:00
BFlat d7749249db Merge pull request #1548 from StrajnarFilip/claude/fix-1219-sa-line-breaks
M4: Thai, Lao, Khmer and Burmese lines break between words (#1219)
2026-10-30 02:34:12 +07:00
bflatastic 1c8b937f08 Merge main into #1548 2026-10-30 02:04:39 +07:00
BFlat 132abcc672 M8: show in the app where an attached AI agent is working (#1416) (#1556)
Session::execute_as records the agent's presence: its name, the file, and the innermost layers its last edit changed or the selection it set, for 8 seconds after its last command. The app shows it as an avatar in the people row and an outline with a name tag on the canvas, in its own colour. Display only: no undo step, never saved, never synced to a live session.
2026-10-30 02:03:27 +07:00
NetterSilverandClaude Haiku 5.5 562aae7606 Reject non-finite hsl()/hsla() components in parse_css
Found while testing crates/color/src/lib.rs:~173-180 (parse_css, hsl/hsla arm)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 19:57:26 +01:00
bflatastic da3c8903ce Merge main into #1548 2026-10-30 01:45:39 +07:00
BFlat 42e1086a31 M5: drag a manual overlay into place on the canvas (#405) (#1555)
While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it. Dragging it sets the action's offset through proto.action.set in one gesture: the X/Y fields follow live and the drag is one undo step.
2026-10-30 01:44:17 +07:00
bflatastic a9d197f17e Merge main into #1548 2026-10-30 01:29:43 +07:00
BFlat e0abd55d69 M9: one clear, persistent display name, renamed live in sessions (#1380) (#1554)
* M9: one name for comments and live sessions, shown in the Join dialog, kept in the browser, renamed live (#1380)

* M9: a reconnecting session's reset is its own helper (#1380)
2026-10-30 01:27:52 +07:00
bflatastic fef5898616 Merge main into #1548 2026-10-30 01:14:55 +07:00
BFlat 0a83cd9bdb Merge pull request #1516 from AganzeFelicite/fix/vector-marquee-anywhere-1508
M4: a drag anywhere in vector edit mode draws a marquee (#1508)
2026-10-30 01:12:34 +07:00
BFlat 3ac8467c54 Merge pull request #1547 from liwaichung-source/main
board grid settings: dots, lines, graph paper or none
2026-10-30 01:01:35 +07:00
bflatastic d7269c63d5 Merge main into #1516 2026-10-30 00:56:23 +07:00
bflatastic 719b78fbd9 Merge main into #1548 2026-10-30 00:54:38 +07:00
BFlat af2df345bc M9: the Join dialog shows a join until its file is ready, with Cancel and Try again (#1381) (#1553) 2026-10-30 00:51:46 +07:00
BFlat 501b9f770c Merge pull request #1544 from Carbocode/fix/color-picker-dismissal-and-placement
M1: dismiss colour pickers outside and anchor them beside the sidebar
2026-10-30 00:44:06 +07:00
bflatastic 4ec382ea86 Merge main into #1547 2026-10-30 00:32:08 +07:00
bflatastic d60d973459 Merge main into #1516 2026-10-30 00:32:07 +07:00
bflatastic ffbb33973c Merge main into #1548 2026-10-30 00:31:50 +07:00
BFlat 4fbed6a4e0 Merge pull request #1520 from storytold/dependabot/github_actions/actions-db49d30d5d
ci(deps): bump storytold/craft-common-actions/.github/workflows/windows-arm64.yml from 8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f to fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 in the actions group
2026-10-30 00:27:33 +07:00
bflatastic 84f1419fbb Merge main into #1544 2026-10-30 00:26:55 +07:00
BFlat b85f38f30c Merge pull request #1521 from Carbocode/feat/corner-smoothing-scrub
M1: add a draggable corner smoothing icon
2026-10-30 00:24:22 +07:00
BFlat 8d884b005a Merge pull request #1496 from samhyun/perf/web-band-render
M8: the browser draws a zoom's full render a band at a time
2026-10-30 00:23:01 +07:00
bflatastic 1917ab80a8 Merge main into #1544 2026-10-30 00:22:26 +07:00
BFlat f6ccef0c5d M9: a per-address budget for server admin requests, and join codes found by hash (#1075) (#1552)
* M9: a per-address budget for server admin requests, and join codes found by hash (#1075)

* M9: the server tests share one snapshot and address helper (#1075)
2026-10-30 00:20:57 +07:00
BFlat 3e16119710 Merge pull request #1507 from Silvertree2010/fix/h3859-export-size-label-rounds-the-sca
Keep full precision in export size labels so they parse back
2026-10-30 00:19:59 +07:00
bflatastic 1562b4bcd3 Merge main into #1548 2026-10-30 00:19:46 +07:00
BFlat 4ed9981504 Merge pull request #1539 from Silvertree2010/fix/h3971-css-variable-export-leaves-carri
Neutralise CR and form feed in CSS variable export strings
2026-10-30 00:17:28 +07:00
BFlat ae0deb44b2 Merge pull request #1545 from Silvertree2010/fix/h3982-node-set-silently-turns-off-isma
Reject non-boolean values for node.set flag properties
2026-10-30 00:15:01 +07:00
bflatastic b00cccb114 #1547: name the grid styles in a table, keeping the Preferences section's complexity low 2026-10-30 00:13:46 +07:00
BFlat 2f7d22e84a Merge pull request #1546 from Carbocode/feat/group-auto-layout-button
M2: add auto layout from groups in the sidebar and convert them in place
2026-10-30 00:12:35 +07:00
bflatastic 44a7c3c16c Merge main into #1521 2026-10-30 00:11:56 +07:00
BFlat 40c9e56876 Merge pull request #1530 from Silvertree2010/fix/1514-bug-report-figcraft-a-translucen
Fix double alpha when binding a translucent variable to an empty fill
2026-10-30 00:10:19 +07:00
BFlat a82adf4657 Merge pull request #1542 from Silvertree2010/fix/1513-bug-report-figcraft-a-variable-n
Resolve stored variable ids by exact id, not by variable name
2026-10-30 00:07:48 +07:00
bflatastic 9d86403f3a #1547: drop the board grid entry from the right-click menu 2026-10-30 00:06:50 +07:00
bflatastic 59af2bc44a #1547: keep the default board dots' size, and test the Preferences grid section 2026-10-30 00:06:49 +07:00
BFlat a51a60650d Merge pull request #1498 from ahmed-226/feat/guide-snapping
M1: guides snap to layer edges and centres when dragged
2026-10-30 00:05:17 +07:00
BFlat cfec7c28cd Merge pull request #1499 from samhyun/fix/web-control-input
M8: the browser applies input sent over its control channel
2026-10-30 00:03:43 +07:00
bflatastic 864acc0425 Merge main into #1547 2026-10-30 00:03:13 +07:00
bflatastic 86ab39b5cc Merge main into #1516 2026-10-29 23:58:44 +07:00
BFlat 2e3d9b6f07 M8: SVG pattern tiles drawn once per scale, within the layer budget (#1080) (#1551) 2026-10-29 23:57:49 +07:00
bflatastic c13c8d5e6a Merge main into #1498 2026-10-29 23:28:32 +07:00
bflatastic 890116d55e Merge main into #1496 2026-10-29 23:25:44 +07:00
bflatastic f2b1b8eadd Merge main into #1544 2026-10-29 23:25:35 +07:00
bflatastic a0fb4c3992 Merge main into #1546 2026-10-29 23:25:32 +07:00
bflatastic 187d091f7b Merge main into #1507 2026-10-29 23:23:12 +07:00
bflatastic 49f6e92758 Merge main into #1539 2026-10-29 23:23:07 +07:00
bflatastic 42844f13c6 Merge main into #1545 2026-10-29 23:23:04 +07:00
bflatastic 463045d2fa Merge main into #1530 2026-10-29 23:23:01 +07:00
bflatastic 59f000ee59 Merge main into #1542 2026-10-29 23:22:57 +07:00
bflatastic d3f382e9c6 Merge main into #1499 2026-10-29 23:22:53 +07:00
bflatastic d92ec78d7b Merge main into #1521 2026-10-29 23:22:45 +07:00
bflatastic 29d1c2b4d9 #1521: Cargo.lock keeps the craft-libs sources of craft-keymap, craft-menu and craft-quit 2026-10-29 23:22:26 +07:00
Filip StrajnarandClaude Opus 5.5 411bce2f81 M4: Thai, Lao, Khmer and Burmese lines break between words (#1219)
These scripts write no spaces between words, so a paragraph was one word
that only broke where it overflowed, mid-word (พัฒนาซอ|ฟต์แวร์).
breaks::word_breaks finds the word starts inside each run of them with
ICU4X's line segmenter (icu_segmenter 2.3, matching the icu_* crates in
the lock), and break_lines may break there when the next glyph starts a
new cluster. Where a run meets other text the UAX #14 subset decides as
before; a word wider than its box keeps #1400's emergency break.

Desktop uses the segmenter's dictionaries; the browser uses its LSTM
models (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB web
gate can't take). ICU4X breaks between stray marks are filtered through
safe_emergency_break, so junk input never splits a cluster.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LvG6DyK6i9Nvvsp3tp77UP
2026-10-29 16:44:14 +01:00
BFlat f702105ed0 M4: a letter and its combining marks shape with one font (#1478) (#1532)
* M4: a letter and its combining marks shape with one font (#1478)

* M4: the stacked-mark test checks positions only in fonts whose anchors stack them (#1478)

* M4: the cluster face rule is tested with stand-in faces, so it runs without system fonts (#1478)

* M4: the #1478 tests use stand-in faces only, so they run everywhere
2026-10-29 22:25:40 +07:00
BFlat 3af5d8a534 M9: comment.list reports the names each message mentions (#1415) (#1534)
Every message (the first and each reply) gets `mentions`: {name, known,
you} for each @mention, by the rule the thread view highlights with
(figcraft_doc::mentions, now shared with mention_ranges). Names resolve
against the file's people and you; unknown names are kept with
known: false; [email protected] mentions nobody.
2026-10-29 22:07:06 +07:00
liwaichung-source 366b981aa7 Merge branch 'storytold:main' into main 2026-10-29 22:53:38 +08:00
liwaichung-source 78500a9537 Merge pull request #1 from liwaichung-source/claude/nifty-shannon-mvsb4q
M12: board grid settings: dots, lines, graph paper or none
2026-10-29 22:53:23 +08:00
Claude 2d507d959d M12: board grid settings: dots, lines, graph paper or none
The board canvas's background grid is now a preference (`board.grid`): dots
(the default), lines, graph paper (a strong line every 2-12 cells over fainter
ones, like Japanese section paper) or none, with spacing (4-512), mark size and
contrast. Preferences gets a Board grid section; right-clicking an empty board
offers Board grid settings. Zoomed out the grid still thins out (by halves, or
down to graph paper's strong lines), and dots no longer shrink to a single
pixel, so the grid stays visible at low zoom.

The renderer draws lines as well as dots, with a configurable strong-mark
rhythm; a crossing of two lines is blended once, in the stronger colour. A
hand-edited preferences file falls back to the default grid instead of losing
the other preferences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FEkCVd8n1eC6xTHebmHMDR
2026-10-29 14:44:18 +00:00
Carbocode a3af72eca9 M1: sync main and leave ROADMAP unchanged in the feature PR 2026-10-29 15:43:01 +01:00
Carbocode 4f67fd2b7c M2: keep the shared roadmap out of the auto layout fix 2026-10-29 15:41:58 +01:00
Carbocode cd0dc748b8 M1: keep the shared roadmap out of the colour picker fix 2026-10-29 15:41:55 +01:00
BFlat 96eed15993 M8: mask groups draw over their own area, nested layers share one memory budget (#1061) (#1535)
A mask group's pixmaps cover only where its mask or content can paint within the view, rounded out to whole pixels, drawing the same pixels (a 40x40 mask on a 3000x2000 view: 96 MB -> 61.5 kB). Isolated layers share a 2 GiB per-thread budget; a layer past it is left out with a warning.
2026-10-29 21:33:46 +07:00
Carbocode f781abe5f5 M2: preserve masks and sizing when adding auto layout to groups 2026-10-29 15:22:29 +01:00
Carbocode a52ebfda2d Merge remote-tracking branch 'origin/main' into fix/color-picker-dismissal-and-placement
# Conflicts:
#	ROADMAP.md
2026-10-29 15:13:25 +01:00
Carbocode 46924f49f4 M1: address colour picker interaction review findings 2026-10-29 15:13:15 +01:00
samhyunandClaude Fable 5.1 3f896453b6 M8: a test for stacked background blurs drawn in bands; the eight-band wording
From the review of #1496: three background blurs stacked on each other across the band
boundaries draw the same in bands as in one piece (within anti-aliasing), so the bands'
overlap, the reach of the largest blur, is enough, as it is for the desktop's parallel bands;
pinned by a test. The docs and the `MAX_BANDS` comment no longer put the longest frame at "an
eighth of the render": a frame draws at least a band, a part of the render rather than the
whole, and the numbers measured for #1496 are cited instead.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-29 23:08:12 +09:00
BFlat 0d9c2c3f73 M3: components that would contain themselves are refused, and loops in files are repaired (#1084) (#1540)
* M2: components can't contain their own instances (#1084)

Edits that would make a component hold an instance of itself, directly, through other components or through another variant of its set, are refused with the loop named. Files holding such a loop open with one instance per loop detached and a note; a rebuild never expands an instance inside a copy of itself, so a loop merged in a live session stops growing.

* M2: a variant may hold a sibling variant's instance; only true loops are refused (#1084)

Each variant counts as its own component: refusals and open-time repairs apply only to a component leading back to itself through instances (an instance-swap value included), so files with nested sibling variants open unchanged. sync_instance_depth is split into match_layers and copy_layers.
2026-10-29 21:05:32 +07:00
Carbocode 38607aa706 M8: use Carbocode public GitHub profile name in contributor credits 2026-10-29 15:04:12 +01:00
Carbocode ec96cae46a M2: add auto layout from groups and convert them in place 2026-10-29 14:57:08 +01:00
NetterSilverandClaude Haiku 5.5 0876f6dbc9 Reject non-boolean values for node.set flag properties
Found while testing crates/engine/src/cmd/props.rs:614 (also :708, :717, :718, :826 and :1007)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:51:04 +01:00
Carbocode 581549617e M1: sync Carbocode contributor name from GitHub 2026-10-29 14:42:59 +01:00
Carbocode 0c7e8626b4 M1: dismiss colour pickers outside and anchor them beside the sidebar 2026-10-29 14:40:56 +01:00
samhyunandClaude Fable 5.1 6d8b0e68f0 M8: the browser draws a zoom's full render a band at a time
Once a zoom had paused, the browser drew its full render in one frame. On a heavy page that
frame took seconds, and no input was handled meanwhile: the wheel steps and drags that came
were seen only when it was over.

The renderer's `Retained::begin` hands a full render back as `Steps`, drawn `band_h` rows at a
time with the same `render_band` and `put_band` the desktop's parallel bands use (bands overlap
by what background blurs read and keep their own rows; a blur reaching past a band makes the
bands taller rather than one piece), and `finish` keeps the image as a full render. A render
that is reused or patched is done at once, as before.

The browser's `Inline` worker refines through `Renders::refine_for`, which draws at least a
band a frame, more while they fit in `STEP_MS` (24 ms), and asks for the next frame while bands
remain, so input is handled between them; a view has at most eight bands, so on a page whose
render takes seconds the longest frame is about an eighth of it. A render drawn for the very
request is done once drawn. A pan or an edit meanwhile at the same zoom keeps the render going
and is patched in once it is drawn; a pan too far to patch or an edit the patch can't take draws
it again in steps, the image just drawn showing till then (`Refined::Shown`); a zoom, another
page or file, another size or other render options drop the render for one of the new view,
and what its bands took stands as the measure the next zoom's preview and wait go by. A render
in steps counts as one render (glass keeps the shapes a later render asks for again). The
desktop, with its render thread, is unchanged.

Measured in Chrome on two synthetic pages (20 images and 60 shadowed rectangles, with and
without two background-blur panels) whose full render takes 3.6–4.0 s at a deep zoom: the
longest frame while that render runs went from 3.7–4.0 s to 0.9–1.4 s (blur) and 0.69–0.80 s
(no blur); the bands together take longer than one render (the refined image comes after
6.7–8.1 s and 5.3–5.5 s instead of 3.8–4.1 s), each band rendering its overlap again and
repeating some per-band work, shadows mostly. The zoom steps are as before.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-29 22:37:35 +09:00
BFlat 3108d5bee7 M8: edit.duplicate returns an id map with map: true (#995) (#1528)
With map: true, edit.duplicate also returns map: each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference instead of inspecting it. Without map the result is unchanged.
2026-10-29 20:34:30 +07:00
Carbocode dd65d7f4d2 Refresh local craft-libs lockfile entries 2026-10-29 14:19:04 +01:00
NetterSilverandClaude Opus 5.5 33ddb44016 Resolve stored variable ids by exact id, not by variable name
Fixes #1513

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:11:48 +01:00
BFlat 436cfa1b96 M9: the replay log is bounded in bytes by default (#1069) (#1524)
The batches a session keeps for apps that reconnect were bounded only by count on a desktop host
and a private figcraft-server (`max_log_bytes` defaulted to usize::MAX). It now defaults to
64 MiB, and figcraft-server takes it from a new `max_log_mb` setting (default 64; open to the
public, also at most a quarter of `max_file_mb` as before). An app behind the trimmed log catches
up with a snapshot, as it already did past the count limits. The protocol is unchanged.

Nine 8 MiB renames with default limits: all 75.5 MB kept before, 58.7 MB after.

Fixes #1069
2026-10-29 20:02:46 +07:00
Carbocode 563d530fdf M1: merge main and resolve corner smoothing roadmap conflict 2026-10-29 13:52:36 +01:00
BFlat 714bbfcdaa M8: MCP and control calls name the document they act on (#998) (#1533)
Tools that act on a document, and the control methods engine.execute,
document.inspect, node.get and ui.render, take an optional `document`
(an open file's id from doc.list / document.inspect, or its path). The
call acts on that file and the tab in front stays in front, so an
attached agent keeps editing its own file when the user switches tabs.
2026-10-29 19:47:41 +07:00
BFlat b2421885d0 M9: sibling placement in live sessions by binary search (#1065) (#1523)
Every replica placed a created, moved or reordered layer by counting each sibling that sorts
before it, so a batch of many layers under one parent took quadratic time, on the server's
single hub thread too. `Replica::slot` now binary-searches the `(key, id)` order on each side of
the layer's old place (a position change has already given it its new key). The order is
unchanged, ties and fractional positions included.

20,000 creations and 10,000 moves under two parents: 20.05 s before, 0.33 s after (debug build).

Fixes #1065
2026-10-29 19:32:30 +07:00
NetterSilverandClaude Haiku 5.5 4d47c350c1 Neutralise CR and form feed in CSS variable export strings
Found while testing crates/engine/src/cmd/variables_io.rs:~92 (css_safe, used by css for String values and mode names)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 13:30:36 +01:00
BFlat 72b4269383 M8: an AI agent attached over MCP comments under its own name (#1414) (#1529)
Every command an MCP client runs acts as the client's name from initialize (clientInfo.title, else .name) with ' (agent)' after it, or 'AI agent' without one, carried to the app as engine.execute {agent}. Its messages, reactions and read state are its own, and its messages are marked (agent: true in the file and comment.list, an Agent label in the thread). The user's name and comments made in the app are unchanged; in live sessions the name and marker travel with the thread.
2026-10-29 19:15:19 +07:00
BFlat 3efb2699b6 M3: the instance-swap menus have a search field that filters as you type (#1338) (#1527) 2026-10-29 19:00:15 +07:00
BFlat 08173cc51e M9: a refused batch's fixes in linear time (#1063) (#1522)
The server's fixes for a refused batch walked every deleted layer's whole subtree again and
compared each document-level fix with all the earlier ones, so a batch deleting a deep chain took
quadratic time on the hub thread. Each subtree walk now stops at a layer already restored, and
document-level fixes are looked up and sent once per target. The fixes are unchanged.

A refused batch deleting a 20,000-deep chain plus 40,000 comment puts: 27.2 s before, 0.14 s after
(debug build).

Fixes #1063
2026-10-29 18:44:03 +07:00
BFlat d4e6ad5d81 M12: the connector tool previews the connector it will make, ends snapping to attach points (#1387) (#1526) 2026-10-29 18:29:48 +07:00
BFlat e32f8f2e07 M8: large images on the canvas draw from a smaller copy (#1483) (#1518)
* M8: large images on the canvas draw from a smaller copy (#1483)

An image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, cached beside the image in the same budget. Two 16000 x 10000 images (640 MB each) evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out. Moving one over the other: about 2.1 s a frame before, 11 ms at fit and 15 ms at 30% after. Exports, thumbnails and views at 100% and closer draw from the image itself. Tests in large_image_tests; timings in large_image_bench_tests (ignored).

* M8: drop the ignored large-image benchmark; its numbers are in the PR (#1483)
2026-10-29 18:14:18 +07:00
NetterSilverandClaude Haiku 5.5 1c3f72681d Fix double alpha when binding a translucent variable to an empty fill
Fixes #1514

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 12:14:17 +01:00
BFlat 0971d096db M8: the progressive JPEG check guards file thumbnails too (#1517)
The header check from #1491 moves to figcraft_format::jpeg so the format crate and the renderer share it, and decode_thumbnail now refuses a progressive JPEG that samples a component more finely than its first (zune-jpeg 0.5.15 asserts on it; in the browser a panic aborts the page). A crafted file's thumbnail on the home screen and in recent files shows no picture instead of crashing. Regression test through decode_thumbnail and archive_preview.
2026-10-29 17:42:53 +07:00
Carbocode bc354430ef M1: preserve fractional corner smoothing across drags 2026-10-29 11:41:54 +01:00
BFlat a492cca415 M6: .fig import resolves library text styles linked by asset key and orders restated override rows by level (#1234) (#1515)
Style links that name a library style by its asset key and version resolve to the file's copy of the style, as variable links already did. The linked text style beats the copies of its values below the row that links it; rows above that one still win. Override rows that restate a nested level (overrideLevel) apply at that level instead of in wire order.
2026-10-29 17:28:55 +07:00
Carbocode 9dab7669cb M1: add a draggable corner smoothing icon 2026-10-29 11:20:13 +01:00
dependabot[bot] f91bc16ad9 ci(deps): bump storytold/craft-common-actions/.github/workflows/windows-arm64.yml
Bumps the actions group with 1 update: [storytold/craft-common-actions/.github/workflows/windows-arm64.yml](https://github.com/storytold/craft-common-actions).


Updates `storytold/craft-common-actions/.github/workflows/windows-arm64.yml` from 8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f to fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
- [Commits](https://github.com/storytold/craft-common-actions/compare/8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f...fa5ef18780cf5e7d5d3b8de068de56cffc83cac5)

---
updated-dependencies:
- dependency-name: storytold/craft-common-actions/.github/workflows/windows-arm64.yml
  dependency-version: fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
  dependency-type: direct:production
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-29 10:07:58 +00:00
BFlat 07681afebe M6: .fig import keeps the file's saved line breaks while the text is unedited (#1236) (#1519)
* M6: .fig import keeps the file's saved line breaks while the text and its box are unedited (#1236)

The line breaker already keeps trailing spaces and the last glyph's letter spacing out of the fit test and doesn't round box widths; imported text wrapped a word early or mid-word because local fonts measure slightly differently from the file's. The import keeps the saved line starts (derivedTextData) on the text with the box width and a stable key of its layout content; layout follows them while both match and no line overflows its box by more than 3% (+1 px).

* M6: local check that imported text wraps at the line breaks its file saved (#1236)

Runs only when FIGCRAFT_FIG_SAMPLES names a folder of .fig files: each fixed-width text with saved breaks wraps there once imported, or falls back by the overflow slack; prints the counts.
2026-10-29 17:04:29 +07:00
Mert DedeandClaude Opus 5.5 c5a9e8f42c Merge main into #1420
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:04:22 +03:00
bd7a277c75 #1420: clearer option errors and text cut-off in the inspect docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-29 12:43:53 +03:00
BFlat 6ef899cdbb M7: download a missing free font from the Missing fonts window (#754) (#1510)
A missing family the suite's font store has (Google Fonts, Noto) gets a
Download button. The first one asks the user to allow downloading fonts,
then craft-libs' craft-font-store installs pinned, checksum-verified files
into the Craft apps' shared font folder on a thread of its own. The font
scan reads that folder; once installed the text fits the real font.

font.download / font.downloads drive the store as its agent: refused while
downloading is off, never able to turn it on. Desktop only.
2026-10-29 16:41:31 +07:00
462b32a642 #1420: test a field from each of the six inspect helpers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-29 12:31:09 +03:00
AganzeFelicite 374ebdb0b9 docs: record the vector edit marquee fix (#1508) 2026-10-29 11:24:30 +02:00
dd55ef63dd Merge main into #1420
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-29 12:18:33 +03:00
AganzeFelicite 3e0e00c3fd test(vector): a marquee starts anywhere on the canvas (#1508) 2026-10-29 11:16:33 +02:00
AganzeFelicite 24a512793c M4: a drag anywhere in vector edit mode draws a marquee (#1508)
A press more than a few pixels from the edited shape left edit mode at
once, so a drag over another layer moved that layer. The press now
starts a marquee; released without a drag it still leaves edit mode and
replays the click to the normal tool.
2026-10-29 11:16:33 +02:00
BFlat 3d5185c2ab M0.9: web wasm back under the 24 MiB gate (#1472) (#1511)
The browser build's wasm was 25,982,540 bytes, past the gate raised for 0.12.1. It is now
23,915,213 bytes (22.8 MiB), 1.2 MiB under the restored 24 MiB gate:

- Catalog rows keep their plural forms behind a reference: Text is two words, not room for
  four strings, across ~52,000 baked-in rows (-454 KB).
- The browser build compiles with wasm SIMD (apps/figcraft-web/.cargo/config.toml, wasm-opt
  --enable-simd), so egui's glyph rasterizer leaves out its scalar SIMD fallback (-1.6 MB).
  The plugin workspace is built from the repository root and stays without SIMD.
- Revert the gate raise (cb227c97); the hosting guide names the WebAssembly SIMD requirement.
2026-10-29 16:14:40 +07:00
BFlat 1df9190d0c M3: shadows of images with transparent pixels follow their alpha (#1289) (#1509)
* M3: shadows of images with transparent pixels follow their alpha (#1289)

A drop shadow on an image fill was hidden behind the layer's whole box, so a
transparent PNG's shadow only showed outside its bounding box. Fills that
paint their own alpha (patterns; images with transparent pixels or that leave
part of the box empty) now cast and hide drop shadows, and shade inner
shadows, by the layer's pixels, like groups. Opaque pictures keep their
outline. Each image is scanned for transparency once. Canvas, PNG, SVG and PDF.

* M3: the image shadow tests import their names explicitly (#1289)
2026-10-29 15:43:08 +07:00
BFlat 7ba22fb2b6 M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464) (#1501)
* M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464)

* M1: crop mode shows the image dimmed outside the layer and heavier crop marks (#1464)

* M1: the handle overlay draws gradients and crops in their own helpers (#1464)
2026-10-29 15:07:21 +07:00
NetterSilverandClaude Haiku 5.5 31d7d5ca7e Keep full precision in export size labels so they parse back
Found while testing crates/doc/src/props.rs:1341 (ExportSize::label, via trim_num at crates/doc/src/text.rs:62)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:06:47 +01:00
Markus Reimr bfb01901fb M10: clarify German gradient, OpenType and startup labels 2026-10-29 08:53:13 +01:00
BFlat 7c9184b023 docs: rows from the round-4 community PRs (#858, #1409, #1397, #1389, #1143, #1427, #1421) (#1504) 2026-10-29 14:48:37 +07:00
BFlat fe7b4bac63 Merge pull request #1143 from Guillergood/feat/penpot-import-export
M6: import and export Penpot 2.x files offline
2026-10-29 14:20:14 +07:00
Ahmed Mahmoud f3017259ff Merge branch 'main' into feat/guide-snapping 2026-10-29 10:08:31 +03:00
BFlat cabf8641fb Merge pull request #1409 from avhn/feat/1386-batch-continue
M0.9: let batches continue after failed steps (#1386)
2026-10-29 14:04:46 +07:00
BFlat 99575f215d Merge pull request #1427 from muamerETS/fix/1385-alt-mid-drag-duplicate
M1: Alt pressed during a drag turns the move into a copy (#1385)
2026-10-29 14:02:06 +07:00
BFlat 68169b8146 Merge pull request #1421 from avhn/feat/1000-variable-props
M3: bind fill and stroke variables directly in layer props (#1000)
2026-10-29 14:01:40 +07:00
BFlat e1c7879957 Merge pull request #1360 from denisg0mes/place-images-at-own-size
M7: place images at their own size, in points for 2x screenshots
2026-10-29 13:52:42 +07:00
BFlat f2d90da58d Merge pull request #1389 from sarsonj/fix/sketch-import-rundogo
M6: Sketch import (#1021 follow-up): match Sketch's renders rule by rule
2026-10-29 13:52:26 +07:00
BFlat b625e48381 Merge pull request #858 from yelintong/feat/panel-grid
M4: the Design panel's sections on the design's 240 pt grid
2026-10-29 13:52:05 +07:00
BFlat 3a82058083 Merge pull request #1397 from StrajnarFilip/codex/fix-1341-figjam-import
M6: Preserve FigJam sticky notes and shapes with text on import
2026-10-29 13:51:49 +07:00
bflatastic 0a6642747a Merge main into #1143
file.open and file.export docs: main's (reload, export options) plus the Penpot format.
2026-10-29 13:51:11 +07:00
Ahmed Mahmoud eedc66bddc M1: address guide snapping review findings
Hidden containers hide their whole branch from snapping, the 5 px reach is converted through the owner's scaled axis so a zoomed frame snaps at 5 screen pixels, and a click on a guide only selects it. Tests cover all three in guide_snap_tests.rs.

Signed-off-by: Ahmed Mahmoud <[email protected]>
2026-10-29 09:47:11 +03:00
bflatastic e89def2068 Merge main into #1409 2026-10-29 13:46:42 +07:00
bflatastic c87be091ac Merge main into #1421
apply_order takes #1458's numbering; a $var wrapper ranks 7, after boundVariables.
2026-10-29 13:46:39 +07:00
bflatastic 7158419698 Merge main into #1427 2026-10-29 13:42:33 +07:00
BFlat c2a6b71020 Merge pull request #1459 from MortenHusted/fix/system-font-names
M7: CSS system font names draw in macOS's System Font
2026-10-29 13:41:53 +07:00
BFlat c2c720087c Merge pull request #1408 from avhn/feat/996-node-find
M0.9: find layer ids without changing the selection (#996)
2026-10-29 13:41:43 +07:00
BFlat 3be354d227 Merge pull request #1461 from MortenHusted/fix/open-file-reload
M1: opening a file that is already open says when it changed on disk, and can read it again (#997)
2026-10-29 13:41:21 +07:00
BFlat f3e7db0b2a Merge pull request #1460 from MortenHusted/fix/unknown-params
M0.9: commands, MCP tools and batch steps refuse names they don't take (#1413)
2026-10-29 13:40:58 +07:00
bflatastic 08ac26299e Merge main into #1143 2026-10-29 13:39:41 +07:00
bflatastic 75b4b5a72b Merge main into #1389 2026-10-29 13:39:38 +07:00
bflatastic a2fb40e3ef Merge main into #1397 2026-10-29 13:39:34 +07:00
bflatastic d4aae905a2 Merge main into #1409 2026-10-29 13:39:30 +07:00
bflatastic 50826b3a32 Merge main into #858 2026-10-29 13:38:45 +07:00
BFlat 9b441ae916 Merge pull request #789 from yelintong/feat/popover
M4: a generic popover —  a 40-high header, dragged by its header
2026-10-29 13:38:12 +07:00
BFlat 8dc3ab5703 Merge pull request #669 from yelintong/feat/flutter-codegen
M7: Flutter code generation
2026-10-29 13:37:49 +07:00
BFlat 299c1d5f59 Merge pull request #1458 from MortenHusted/fix/layout-key-order
M2: auto layout settings in one call don't depend on how their keys are spelled
2026-10-29 13:37:32 +07:00
bflatastic 0bac4f5083 Merge main into #1360 2026-10-29 13:36:26 +07:00
BFlat 611ff941f0 Merge pull request #634 from Skyfuller1303/feat/fig-vector-region-fills
M4: preserve vector region fills across .fig import and native rendering
2026-10-29 13:35:07 +07:00
samhyunandClaude Fable 5.1 f0996645b1 M8: the browser applies input sent over its control channel
`ui.input` over `window.figcraft` (the page's control channel, `?control`) never did anything in
the browser: the batch was queued for the next frame as on the desktop, but the browser shell
has no `raw_input_hook`, the eframe callback that hands a queued batch to the frame about to
draw (`windows::take_input`). The events stayed in their queue and the request's promise never
settled, so an agent driving the page could run commands and take screenshots but not point,
click or type.

The browser shell now has the hook the desktop app has: the batch goes into the frame's raw
input, is applied by it and is acknowledged once the frame is presented, as on the desktop.

Checked in Chrome on the release web build: `ui.input` with a key (O, R, F: the ellipse,
rectangle and frame tools) and with a pointer gesture (a drag with the rectangle tool: a
rectangle drawn) resolves and takes effect, where on `main` the same requests time out and
change nothing.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-29 15:34:14 +09:00
BFlat 1497d8ddc4 Merge pull request #1210 from yelintong/feat/nav-toolbar
ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button
2026-10-29 13:34:02 +07:00
Ahmed Mahmoud 475a7da502 M1: guides snap to layer edges and centres when dragged
Dragging a guide, or moving one, now snaps to nearby layer edges and centres on the guide's own axis: within 5 screen pixels, gated by Snap to Objects and suspended with Cmd/Ctrl. Page guides see the page's top-level layers; frame guides see the frame's box, its layout grids and every visible layer inside it, in the frame's own space.

Snapping sits in the interaction path (canvas/guides.rs, not guide.add/guide.move) so the commands stay deterministic for the control channel and MCP. Tests: guide_snap_tests.rs in the engine and ui-egui crates; docs/ui-parity.md updated.

Signed-off-by: Ahmed Mahmoud <[email protected]>
2026-10-29 09:00:47 +03:00
bflatastic 4b743fee95 #1143: split add_layer (an importer context instead of 19 arguments and its allow), import_themed_tokens and the exporter's shape for cognitive_complexity 2026-10-29 12:54:49 +07:00
bflatastic 1e22268602 Merge main into #1143 2026-10-29 12:44:42 +07:00
bflatastic 67c69f86b6 #634: drop the removed import warning from the Arabic catalog 2026-10-29 12:40:47 +07:00
bflatastic 2a63418d57 #634: region fills drawn by their own helper, and the long region-paint test split by case, for cognitive_complexity 2026-10-29 12:28:47 +07:00
bflatastic 789fa33444 Merge main into #634 2026-10-29 12:25:06 +07:00
bflatastic f6407b1ba0 Merge main into #1389 2026-10-29 12:24:46 +07:00
bflatastic 65d6424bb6 Merge main into #1397 2026-10-29 12:24:45 +07:00
bflatastic dde58e35b9 Merge main into #1427
# Conflicts:
#	docs/roadmap.md
2026-10-29 12:24:43 +07:00
bflatastic fc6ea73154 Merge main into #1421
# Conflicts:
#	crates/engine/src/cmd/props.rs
#	docs/roadmap.md
2026-10-29 12:24:32 +07:00
bflatastic c6c11471db Merge main into #1408
# Conflicts:
#	crates/engine/src/cmd/edit.rs
#	docs/roadmap.md
2026-10-29 12:23:48 +07:00
bflatastic c5ba6dd2e8 Merge main into #1409
# Conflicts:
#	crates/engine/src/session/undo.rs
#	docs/roadmap.md
2026-10-29 12:23:11 +07:00
bflatastic f6df0b4513 Merge main into #1459 2026-10-29 12:22:30 +07:00
bflatastic d1e96ea706 Merge main into #1461 2026-10-29 12:22:30 +07:00
bflatastic ee12a51045 Merge main into #1458 2026-10-29 12:22:29 +07:00
bflatastic a51e815073 Merge main into #1460 2026-10-29 12:22:28 +07:00
bflatastic 26118d212b #858: the stroke row test from #1452 checks the grid's 8 pt right margin 2026-10-29 12:17:12 +07:00
bflatastic 7c0c5fd9d4 #1389: image override tests use PNGs the importer can decode (an undecodable image now maps to the shared placeholder) 2026-10-29 12:16:52 +07:00
bflatastic 305a57a96f #858: section titles stay semibold; ui-parity rows say so; ROADMAP revision row 2026-10-29 12:12:02 +07:00
bflatastic ee4c7b0d98 Merge main into #858 2026-10-29 12:11:33 +07:00
bflatastic 44a9b10e46 Merge main into #1389
# Conflicts:
#	crates/engine/src/cmd/file.rs
#	docs/file-format-parity.md
2026-10-29 12:08:39 +07:00
bflatastic 676681302a #1397: import generated_record now that the .fig mapper modules name their imports 2026-10-29 12:07:34 +07:00
bflatastic 73d913dae3 Merge main into #789 2026-10-29 12:05:45 +07:00
bflatastic 7081bbdd55 #1397: one ROADMAP progress-log entry and revision row for the FigJam import fix 2026-10-29 12:03:03 +07:00
bflatastic 7e90148fd7 Merge main into #1397
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/format/src/fig/map/layer.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-29 12:01:19 +07:00
bflatastic ce14b431da #669: explicit imports in flutter.rs, a capped layer's name stays on its comment line, the Flutter variant asserts in their own test, the Arabic catalog's key 2026-10-29 11:59:14 +07:00
bflatastic 2f3dc8cccd Merge main into #1427
# Conflicts:
#	docs/roadmap.md
2026-10-29 11:54:42 +07:00
bflatastic a81efe5466 Merge main into #669 2026-10-29 11:52:03 +07:00
bflatastic 9f5178f53a Merge main into #1421
# Conflicts:
#	crates/engine/src/cmd/props.rs
#	crates/engine/src/selection/selection_colors.rs
#	docs/roadmap.md
2026-10-29 11:50:45 +07:00
BFlat cabfc9f8fa Merge pull request #1407 from avhn/fix/1113-attach-reply-cap
M8: bound attached MCP replies to 512 MiB (#1113)
2026-10-29 11:50:03 +07:00
bflatastic a48f43cd34 #1408: import opt_id for node.find now that edit.rs names its imports 2026-10-29 11:49:37 +07:00
bflatastic 8d69b3ce8f #1210: revision rows, and drop the Arabic catalog's Resources key 2026-10-29 11:44:05 +07:00
bflatastic 9b3683036c Merge main into #1210 2026-10-29 11:43:04 +07:00
bflatastic 38f63e2da0 #1408: label node.find in the catalogs that label node.get 2026-10-29 11:41:58 +07:00
BFlat 1a755ccb0c Merge pull request #1425 from Silvertree2010/fix/h3041-node-set-opacity-1-gives-full-op
Treat a % suffix on node.set opacity as a percentage
2026-10-29 11:41:54 +07:00
BFlat 27c3232930 Merge pull request #1426 from Silvertree2010/fix/h3039-color-of-silently-drops-non-nume
Reject colour arrays containing non-numeric entries in color_of
2026-10-29 11:41:48 +07:00
BFlat 53726b6248 Merge pull request #1444 from Silvertree2010/fix/1067-audit-col-5-id-space-exhaustion
Keep session joins working when a document has out-of-range ids
2026-10-29 11:41:40 +07:00
BFlat 10b3b1d499 Merge pull request #1404 from Silvertree2010/fix/h2943-looks-like-svg-rejects-svg-docum
Fix looks_like_svg rejecting DOCTYPE with an internal subset
2026-10-29 11:41:04 +07:00
bflatastic 49a41cddd4 Merge main into #1408
# Conflicts:
#	docs/roadmap.md
2026-10-29 11:40:48 +07:00
bflatastic 0265f066b3 #1409: split a long batch test for the cognitive complexity lint 2026-10-29 11:40:21 +07:00
bflatastic 1505681a54 Merge main into #1407 2026-10-29 11:39:50 +07:00
BFlat 031813d068 Merge pull request #1411 from Silvertree2010/fix/h2912-vector-drawable-export-writes-an
Clamp Vector Drawable numbers to the f32 range so they stay finite
2026-10-29 11:38:47 +07:00
BFlat 18abd3ab48 Merge pull request #1467 from Silvertree2010/fix/h3521-node-set-paddinghorizontal-paddi
Reject non-numeric paddingHorizontal/paddingVertical in node.set
2026-10-29 11:38:41 +07:00
BFlat c3f8790f0b Merge pull request #1469 from Silvertree2010/fix/h3578-page-duplicate-accepts-a-non-pag
Reject non-page ids in page.duplicate
2026-10-29 11:38:31 +07:00
BFlat f90a7c5e22 Merge pull request #1419 from avhn/fix/1410-batch-undo-rollback
M8: keep undo entries after a failed batch undoes an earlier edit (#1410)
2026-10-29 11:38:24 +07:00
BFlat e8204fdc80 Merge pull request #1417 from Silvertree2010/fix/h2966-nodeid-from-str-accepts-a-sign-i
Reject '+' sign in NodeId::from_str
2026-10-29 11:38:16 +07:00
BFlat bc1319ccf0 Merge pull request #1432 from Zaxaerith/fix/1428-set-as-thumbnail
fix(engine): make Set as thumbnail affect saved files (#1428)
2026-10-29 11:38:08 +07:00
BFlat c9aa95e437 Merge pull request #1479 from Silvertree2010/fix/1477-auto-width-text-aligned-right-ex
Keep the right edge of right-aligned auto-width text as it grows
2026-10-29 11:38:01 +07:00
BFlat d19125430c Merge pull request #1398 from Silvertree2010/fix/h2890-sketch-import-has-no-total-image
Cap Sketch image members by compression ratio and total size
2026-10-29 11:37:22 +07:00
BFlat 80ffd8308c Merge pull request #1405 from Silvertree2010/fix/h2933-cornerradius-padding-strokesides
Reject cornerRadius/padding/strokeSides arrays with non-numeric entries
2026-10-29 11:35:03 +07:00
bflatastic 48a39ca7aa #1409: test that a continued batch reports only the live events of the steps that stayed 2026-10-29 11:32:49 +07:00
bflatastic d8a4de1bd5 Merge main into #1360 2026-10-29 11:28:49 +07:00
bflatastic 56196eac5a Merge main into #1409
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/session/undo.rs
#	docs/roadmap.md
2026-10-29 11:27:45 +07:00
bflatastic 8c974023c4 Merge main into #1459 2026-10-29 11:23:08 +07:00
BFlat e41f484733 Merge pull request #1439 from Guillergood/codex/round-dash-dots
M4: draw dots for zero-length round dashes
2026-10-29 11:20:12 +07:00
bflatastic f154011299 Merge main into #1461 2026-10-29 11:19:05 +07:00
bflatastic 4fcc49aa37 Merge main into #1458 2026-10-29 11:15:01 +07:00
bflatastic f99f01d57b Merge main into #1460 2026-10-29 11:09:30 +07:00
bflatastic fd81433a23 #1460: text.style lists the text-decoration and variation keys the Typography panel sends, paragraph spacing on selected characters goes to node.set, MCP builds its tool catalogue once, and a test for the params panels build 2026-10-29 11:09:09 +07:00
BFlat 60ea32f171 Merge pull request #1465 from phonedu/mcp-agent-feedback
M12: MCP comment feedback and live change events
2026-10-29 10:51:24 +07:00
BFlat 91cc0f1fad Merge pull request #1406 from yelintong/feat/constraint-guides
ui: show constraint guides for a selected layer in its frame
2026-10-29 10:51:17 +07:00
BFlat 1611e14453 Merge pull request #1400 from SusieeTheLinuxUser/fix/1219-thai-line-breaks
M4: Thai lines keep marks and leading vowels with their letter (part of #1219)
2026-10-29 10:50:28 +07:00
BFlat 2e6612e002 Merge pull request #1457 from culiulemon/pr/misc-fixes
feat: property copy/paste, blend-mode hover preview and clipboard fixes
2026-10-29 10:47:30 +07:00
BFlat fbc1cc63ba Merge pull request #1462 from Skyfuller1303/fix/home-tab-sidebar-alignment
M7: line the Home tab's divider up with the icon rail
2026-10-29 10:45:54 +07:00
bflatastic 9202b1032f Merge main into #1460 2026-10-29 10:38:06 +07:00
bflatastic a718332571 Merge main into #1439 2026-10-29 10:33:54 +07:00
bflatastic 23e16be7f5 #1398: share the member size check between JSON and images (capped_member)
# Conflicts:
#	crates/format/src/sketch.rs
2026-10-29 10:30:56 +07:00
bflatastic ca84502ffd Merge main into #1398
# Conflicts:
#	crates/format/src/sketch.rs
2026-10-29 10:30:35 +07:00
bflatastic 61c5b1c0b8 Merge main into #1407
# Conflicts:
#	docs/roadmap.md
2026-10-29 10:30:23 +07:00
bflatastic aa5c016518 Merge main into #1419
# Conflicts:
#	docs/roadmap.md
2026-10-29 10:30:12 +07:00
bflatastic 069449e33c #1425: share fraction_of for opacity, fill opacity, innerRadius and arcInner 2026-10-29 10:29:16 +07:00
bflatastic 6a652c594b Merge main into #1411 2026-10-29 10:29:16 +07:00
bflatastic d5494fc989 Merge main into #1425
# Conflicts:
#	crates/engine/src/cmd/props.rs
2026-10-29 10:29:16 +07:00
bflatastic d03f385f6c Merge main into #1426 2026-10-29 10:29:14 +07:00
bflatastic 5721140cb4 Merge main into #1405 2026-10-29 10:29:13 +07:00
bflatastic 5db45f11b3 Merge main into #1467
# Conflicts:
#	crates/engine/src/cmd/props.rs
2026-10-29 10:29:12 +07:00
bflatastic 53d4676e1f Merge main into #1469 2026-10-29 10:29:10 +07:00
bflatastic 837cad5d25 Merge main into #1404 2026-10-29 10:29:09 +07:00
bflatastic a5e8330aee Merge main into #1417 2026-10-29 10:29:08 +07:00
bflatastic 08a36418a6 #1444: accept imported entries from unissued spaces; reserve only the top half (NO_SESSION_SPACE) 2026-10-29 10:29:07 +07:00
bflatastic 6b205dd1b5 Merge main into #1444 2026-10-29 10:29:07 +07:00
bflatastic d3551f3c6c #1432: accept components and instances as thumbnails; move the tests to thumbnail_tests.rs 2026-10-29 10:29:06 +07:00
bflatastic c49acd1db9 Merge main into #1432 2026-10-29 10:29:05 +07:00
bflatastic b9db7dce25 Merge main into #1479 2026-10-29 10:29:04 +07:00
BFlat da1cf8da3a Merge pull request #1466 from hussain-hz/ar/figcraft-catalog
i18n(ar): add Arabic draft interface catalog
2026-10-29 10:18:37 +07:00
BFlat 4ae1b7c574 Merge pull request #1491 from samhyun/fix/jpeg-finer-than-first
M8: a progressive JPEG sampling a component finer than its first is refused, not decoded
2026-10-29 10:12:58 +07:00
bflatastic 0cc380c228 Merge main into #1466 2026-10-29 10:08:11 +07:00
BFlat a8753e4aca Merge pull request #1484 from StrajnarFilip/claude/fix-922-scaled-instances
M3: scaled instances keep their scale when their main is rebuilt (#922)
2026-10-29 10:07:47 +07:00
bflatastic 6abddb0f68 Merge main into #1465 2026-10-29 09:55:55 +07:00
bflatastic 158d402ee7 Merge main into #1457 2026-10-29 09:55:12 +07:00
bflatastic fe56681075 Merge main into #1406 2026-10-29 09:55:07 +07:00
bflatastic 1bb73d6809 Merge main into #1400 2026-10-29 09:54:58 +07:00
bflatastic d7945584b3 Merge main into #1462 2026-10-29 09:54:48 +07:00
bflatastic 233d92b022 Merge main into #1439 2026-10-29 09:54:41 +07:00
BFlat 7f646aac24 Merge pull request #1468 from samhyun/perf/web-zoom-settle
M8: the browser refines a zoom only once it has paused
2026-10-29 09:49:26 +07:00
BFlat 4a0bd79797 Merge pull request #1488 from ChristopherLinnett/issue/1086-image-validation
fix(collab): image entries must match their hash and sane bounds (#1086)
2026-10-29 09:49:20 +07:00
bflatastic 8440cb80f9 Merge main into #1484 2026-10-29 09:48:13 +07:00
BFlat d2811b9d86 Merge pull request #1486 from StrajnarFilip/claude/fix-1059-fig-embedded-copies
M6: .fig embedded images are copied once per blob, not once per hash (#1059)
2026-10-29 09:44:38 +07:00
BFlat 5804c0e2cc Merge pull request #1489 from ChristopherLinnett/issue/857-confirm-quit
fix(app): ask before ⌘Q quits with unsaved changes (#857)
2026-10-29 09:41:19 +07:00
BFlat 738f7c7692 Merge pull request #1470 from watchakorn-18k/fix/context-menu-frame-label
fix(ui): right-click on a frame's name label picks that frame
2026-10-29 09:37:58 +07:00
BFlat 3a86db900d Merge pull request #1424 from samhyun/fix/keepalive-ping-under-presence
M9: a ping every keep-alive interval, whatever else the client sends
2026-10-29 09:37:20 +07:00
BFlat ea2a8ea84a Merge pull request #1443 from devius/ka-new-strings
i18n(ka): Georgian for 76 strings added since the language landed
2026-10-29 09:35:42 +07:00
BFlat 84e0ea5449 Merge pull request #1393 from kler/swedish-ui-2026-10-26
i18n(sv): translate 47 new strings
2026-10-29 09:35:37 +07:00
bflatastic e83f9691b3 Merge main into #1466 2026-10-29 09:32:26 +07:00
bflatastic 7c413dfe0a Merge main into #1486 2026-10-29 09:32:05 +07:00
bflatastic a6b41b2f4c Merge main into #1484 2026-10-29 09:31:53 +07:00
BFlat cff1d0bf45 Merge pull request #1487 from ChristopherLinnett/issue/1096-openurl-gesture
fix(present): prototype links open only behind the notice or a viewer's click (#1096)
2026-10-29 09:30:04 +07:00
BFlat baaafc381b Merge pull request #1485 from StrajnarFilip/claude/fix-1476-guides-reopen
M1: page ruler guides survive save and reopen (#1476)
2026-10-29 09:29:31 +07:00
BFlat 95fd3c2444 Merge pull request #1482 from StrajnarFilip/claude/fix-1452-stroke-weight
M1: the stroke weight keeps room for its value beside the variable button (#1452)
2026-10-29 09:29:26 +07:00
BFlat 712074ae2d Merge pull request #1481 from StrajnarFilip/claude/fix-1085-normalize-guard
M8: normalising after a command runs under the panic guard (part of #1085)
2026-10-29 09:29:21 +07:00
BFlat f8355c0680 Merge pull request #1480 from StrajnarFilip/claude/fix-1078-linear-repair
M8: repair and library manifests check membership by set lookups (#1078)
2026-10-29 09:28:53 +07:00
bflatastic 170f1fdf32 #1466: gaps and ROADMAP list the Arabic draft as shipped 2026-10-29 09:26:15 +07:00
Christopher Linnett 57cf1bec48 fix(collab): keep in-use images and resync clients after filtered history (#1086)
- An image a layer or style still shows can't be removed: removing it and
  putting other bytes under its key would swap the picture that layer
  shows, which giving the key other bytes directly can't. A batch made by
  the diff stops using an image (node changes, then styles) before it
  removes it; an undo keeps the changes that apply, so its image may stay.
- Restore and replay leave out the image entries history written under
  older rules can't apply, using one method shared with the undo history
  (Replica::apply_kept, extracted from history.rs). It is exact for every
  image rule, including removals in use and replacements, and leaves out
  only image entries: any other change that doesn't apply is still an
  error. The image-only filter it replaces is gone.
- A server whose restore or replay left image entries out, or whose saved
  log puts images, catches every resuming client up with its snapshot,
  including one at the same sequence number.
- Tests: an_image_in_use_cannot_be_removed,
  an_image_no_longer_in_use_can_be_removed, undoing_an_image_layer_still_works,
  a_restart_replays_past_a_logged_removal_of_an_image_in_use,
  a_resume_after_history_was_left_out_catches_up_with_the_snapshot,
  a_resume_after_a_clean_restart_catches_up_with_the_log,
  a_snapshot_with_a_broken_layer_change_still_fails_to_open.
- Docs: collab-protocol.md, collaboration-parity.md.
2026-10-29 10:25:16 +08:00
bflatastic 6c18fe916b Merge main into #1466 2026-10-29 09:23:22 +07:00
bflatastic 2bc14d946b #1465: split the event and prompt code for cognitive complexity, drop the panel header button 2026-10-29 09:20:55 +07:00
bflatastic 1ed9eb6b8c Merge main into #1393 2026-10-29 09:20:18 +07:00
bflatastic 0da3a564ca Merge main into #1443 2026-10-29 09:20:09 +07:00
bflatastic dfe83f63f0 Merge main into #1470 2026-10-29 09:19:59 +07:00
bflatastic 9d42833008 Merge main into #1465 2026-10-29 09:18:39 +07:00
bflatastic 5f58acdb35 #1406: guides for an absolute child of an auto layout frame, and a test 2026-10-29 09:17:25 +07:00
samhyunandClaude Fable 5.1 bdddf9a2ba M8: the baseline fixture of the JPEG sampling test is a well-formed file
From the review of #1491: patching a component's sampling in the header of a baseline JPEG
`image` wrote leaves its entropy data for 1 × 1 components, so decoding it could only fail and
the test proved nothing past the frame not being refused. The fixture is now a baseline CMYK
JPEG as jpeg-encoder writes it with 2 × 2 sampling (its black sampled finer than its cyan), and
the test asserts it is left to the decoder and decodes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-29 11:16:17 +09:00
bflatastic fba0ae85c3 Merge main into #1406 2026-10-29 09:14:58 +07:00
bflatastic 4826d54a3d #1400: pass chars and clusters into break_lines, safe break in safe_break_index 2026-10-29 09:13:08 +07:00
bflatastic 49bf4b0fd8 Merge main into #1400 2026-10-29 09:11:47 +07:00
bflatastic fdaaeaa218 Merge main into #1462 2026-10-29 09:11:30 +07:00
bflatastic 091640bfb2 Merge main into #1439 2026-10-29 09:07:04 +07:00
Nicek 1c253ef30d fix: pasted image keys stay archive-valid when the content hash is taken
The blend menu and its hover preview now live beside upstream's split menu module.
2026-10-29 09:46:50 +08:00
Christopher Linnett 7cce115ad6 fix(collab): check old images against the state they apply to (#1086)
- without_refused_images judges each image entry against the document it
  applies to and the batch's earlier entries (image_for, shared with
  apply): a logged put of other bytes under a key the replay state holds
  is left out, so a restart no longer stops there and drops the rest of
  the log.
- Server::restore keeps its saved log only when the log puts no images:
  a resuming client may already hold other bytes under a logged key, which
  the restored state can't check, so clients catch up with the checked
  snapshot instead.
- Tests: a_restart_replays_past_a_logged_image_replacement,
  a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up,
  a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up.
- Docs: collab-protocol.md, collaboration-parity.md.
2026-10-29 09:37:33 +08:00
Nicek fae356fd50 fix: 粘贴图片的内容哈希键被占用时改用空闲键 2026-10-29 09:33:55 +08:00
Nicek 4c0eb71a4c fix: 粘贴图片填充时避开目标文件已占用的图片键
目标文件已有同名键但数据不同时,粘贴的 paint 改用复制数据的内容哈希键,避免渲染成目标文件里的图片;目标文件原图片保留给已有图层。
2026-10-29 09:33:55 +08:00
Nicek c6ba81b6e6 test: 同帧按下/松开绘制从按下点开始的回归用例 2026-10-29 09:33:55 +08:00
Nicek dd93a6a2a8 fix: 修复 PR #1457 的选择与拖拽评审问题
- 标签在选择边界外也参与收窄,Dev Mode 单击已选图层只选它
- 区分点击命中图层与描边空白处的拖拽回退目标
- 重排圆环按按下时的修饰键判断点击,画布外松手视为拖拽结束
- 修复 clippy unnecessary_get_then_check
2026-10-29 09:33:55 +08:00
Nicek f8ddb5a0a8 feat: 复制属性后显示提示 2026-10-29 09:33:55 +08:00
Nicek 7ea6a424e9 chore: Windows 打包签名、忽略本地目录、贡献者署名 2026-10-29 09:33:54 +08:00
Nicek ce93dc6ff5 feat: 混合模式悬停预览、多选层点击与图层属性预览 2026-10-29 09:33:54 +08:00
Nicek 88477723c5 fix: ArkUI 百分比间距输出与同帧按下/松开坐标 2026-10-29 09:33:54 +08:00
Nicek bb495bcf70 feat: 属性与样式复制粘贴,含图片/图案跨文件处理与多语言 2026-10-29 09:33:20 +08:00
samhyunandClaude Fable 5.1 9aac391e04 M8: a progressive JPEG sampling a component finer than its first is refused, not decoded
zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler on a progressive frame
whose later component is sampled more finely than its first (etemesi254/zune-image#236; an 8 × 8
Y 1×1, Cb 2×2 file is enough). The panic reached everywhere such an image is decoded: the CLI died
writing a thumbnail, the desktop canvas's guard caught it on every render of the page, and in
the browser it aborted the tab. The fix in zune-jpeg (36007420) is in 0.5.16 pre-releases only,
which `image`'s requirement doesn't take.

Such a JPEG is now refused from its frame header before the decoder sees it, as an image past
the byte budget is. The decoder refuses the larger ones of this shape itself, so nothing that
decoded is lost; baseline frames of the same shape decode and are left to it. The check can go
once zune-jpeg 0.5.16 ships.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-29 10:29:56 +09:00
Christopher Linnett 949d74f7ca fix(collab): image checks keep documents shareable and history replayable (#1086)
- The entry size cap is figcraft_doc::ImageData::MAX_BYTES (256 MiB), the
  cap the .fig, .sketch and REST importers already read images with (now
  one constant), instead of 32 MiB: an imported image larger than 32 MiB
  made the document unjoinable and its server snapshot unrestorable.
- History written before the checks (a restored or joined snapshot, a
  replayed server log, a file published to a server) applies without the
  image entries they refuse (docprops::without_refused_images), instead of
  the file not opening or a restart dropping every edit after the image.
- A key that already holds an image can't be given other bytes: producers
  key images by their bytes (the REST importer's placeholder keys always
  hold the same picture), and merge treats a key's image as never
  changing.
- REST import: an unresolved imageRef shaped like a content hash shares
  the rest-image-missing placeholder (the placeholder's bytes don't hash to
  it); ImageData::claims_content_hash is shared with collab.
- Tests: collab image_tests (4 new, size test uses a small cap), format
  image_tests (1 new).
- Docs: collab-protocol.md, collaboration-parity.md, file-format-parity.md.
2026-10-29 08:55:03 +08:00
Christopher Linnett 0e508fd316 fix(app): a second quit before the window draws counts once (#857)
The app's logic runs while the main window is minimised, without the
frame that acts on a close. Two quits there queued the main window's close
twice: the first let it take over another window, and the second then
quit the app over that window's unsaved changes, as answered.

- close_current queues a window's close only once.
- Test: quitting_twice_before_the_window_draws_closes_it_once.
- docs/hardware-parity.md revision history.
2026-10-29 08:50:00 +08:00
Christopher Linnett 7660877845 fix(present): nothing opens or releases behind an open link notice (#1096)
- finish(): a viewer-fired link opens directly (notice skipped) only while
  no notice shows; any other link that fires while one shows is dropped.
- finish(): raising a notice ends the press under way, so its release
  doesn't fire the prototype's mouse-up actions (a link, a navigation)
  behind the notice, which takes no input while it shows.
- The mail notice says the link opens the mail app to write a message
  (opening a mailto: link doesn't send anything).
- Tests: nothing_opens_while_a_notice_shows_with_the_notice_skipped,
  a_press_that_raised_a_notice_doesnt_release_behind_it.
- docs/prototyping-parity.md row and revision history.
2026-10-29 08:50:00 +08:00
Christopher Linnett f1c2ee81e4 fix(collab): image entries must match their hash and sane bounds (#1086)
Image entries applied bytes to any key with no checks (audit COL-9): a
collaborator could poison the shared content-addressed image store, claim
absurd dimensions, balloon memory, or name an image `../../x`, which a
saved file then wrote as the archive member `images/../../x`.

- Every key must be 1–128 ASCII letters, digits, `-` or `_`:
  `figcraft_doc::ImageData::is_valid_key`, promoted from the format
  crate's private `is_valid_hash` so the file reader and collab share one
  rule. The reader now also takes `-` and `_`, so the REST importer's
  `rest-image-missing` placeholder survives a save and load.
- The REST importer's side of the same attack: an unresolved `imageRef`
  that can't legitimately be an archive member name (path text, an
  over-long ref) shares the `rest-image-missing` placeholder instead of
  being keyed by its raw name
  (`image_tests::a_path_like_rest_image_ref_never_names_an_archive_member`).
- A key with the shape of a content hash (64 hex chars) must be exactly
  the lowercase sha256 of the bytes, the form `image_hash` writes; an
  uppercase look-alike is refused. Other keys (svg FNV-128, REST
  placeholders) claim no content address and only meet the rules above.
- A side is at most 65 535 px; decoded bytes at most 32 MiB, with the
  base64 text capped before decoding. 0×0 stays valid: the format crate
  stores it for sizes it can't probe.
- Refusals are Error::Invalid and reject their batch, so every replica's
  store stays clean; catch-up snapshots apply through the same check.
- Docs: collab-protocol.md, collaboration-parity.md and
  file-format-parity.md (rows, status lines, histories).
- Tests: collab image_tests (8), format image_tests: round-trip and the
  REST placeholder gate.
2026-10-29 08:11:13 +08:00
Christopher Linnett 73398cedf1 fix(app): ask before ⌘Q quits with unsaved changes (#857)
winit's default macOS app menu ends the process through AppKit's
`terminate:` at once, so ⌘Q and Quit from the menu bar never asked about
unsaved changes (the recovery copies were all that survived).

- Install craft-libs' app menu (craft-menu, same rev as the other
  craft-libs pins; macOS only). Its Quit sends the main window a close
  request, the same path as the window's close button, so
  windows::on_close_request cancels the close and the unsaved-changes
  question asks first. Failure to install keeps winit's menu and logs it.
- windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer pins
  the contract the menu relies on: a close request with unsaved changes
  sends CancelClose and asks; Don't Save then closes.
- Like the close button it closes the main window only: with several
  windows open, each quit closes one (documented).
- docs/hardware-parity.md macOS row, status line and revision history.
- Cargo.lock: craft-menu, craft-quit, craft-keymap, muda and their deps;
  cargo's own minimal resolution also moves a few windows-sys/getrandom
  edges to versions already in the lock.
2026-10-29 08:11:13 +08:00
Christopher Linnett 53f47706ec fix(present): prototype links open only behind the notice or a viewer's click (#1096)
An Open link action carries a URL from the document and fires from whatever
trigger the prototype sets, so an unattended presentation could open web links
and mail messages one after another: a mail link bypassed the leaving-the-
prototype notice entirely, and a skipped notice let any trigger open any
number of links.

- finish() routes every openable URL, mail included, through the notice.
- Skipping the notice ("Don't show this again") opens at once only a link the
  viewer's own click, press, drag or key fired, one per frame. A delay, the
  pointer passing over a layer (enter, leave, hover) and a timed mouse
  trigger that fires after the gesture is over still show the notice, which
  then offers no checkbox: it couldn't skip that notice anyway.
- The player tags each queued link with whether the viewer fired it
  (Presenter::fire_from); an OpenUrl outside an interaction defaults to not.
- The notice covers mail links (new locale string, mail wording).
- Engine prefs/command doc text follows the rule; behavior untouched there.
- present_links_tests: shared setup helpers for the link tests; new
  tests for the skip rule (delay, hover, delayed mouse-up, key, one link per
  click, checkbox only for clicked links); the scheme-filter test now clicks
  with the notice skipped so the filter alone keeps those URLs out.
- docs/prototyping-parity.md row, status line and revision history updated.
2026-10-29 08:11:13 +08:00
Filip StrajnarandClaude Opus 5.5 652bf35b19 M1: page ruler guides survive save and reopen (#1476)
A page kept its ruler guides in `NodeKind::Page`, and since frame guides
(#70) every layer also has its own `guides` field. Both are written as
`guides`, and on reading the layer's own field takes the key, so:

- a reopened page's guides landed in the layer field and were not drawn;
- saving that page wrote `guides` twice (the page kind's list, always
  written, then the layer's), and the file no longer opened ("duplicate
  field `guides`").

Every layer, pages included, now keeps its guides in the one layer field;
the key in the file is unchanged, so existing files open with their guides.
The field reads every `guides` list a layer has, so files already saved
with the key twice open again with both lists, in file order. This also
covers collab `Create` payloads from older peers.

Regressions: JSON round trips of page and frame guides, a page with two
lists, a bad list still refused (doc); save, reopen, save, reopen through
`.figcraft` and a document.json with the key twice (format). Checked in the
app over the control channel: guides drawn after two save/reopen cycles, and
a file with the key twice opens with all its guides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
2026-10-28 22:47:24 +01:00
Filip StrajnarandClaude Opus 5.5 3009d19fde M6: .fig embedded images are copied once per blob, not once per hash (#1059)
Images carried inside a `.fig` message (`image.dataBlob` indexing the
message's `blobs`) were copied out before the message is dropped, one copy
per image hash. Nothing stops a file from pointing many hashes at one
blob: 100 hashes on a 250 MB blob made 25 GB of copies, an out-of-memory
abort from a small file.

The walk now records each hash's blob index, and `embedded_copies` makes
one shared copy per blob for every hash that names it, so the copies are
bounded by the message's own size. Each resolved image is still copied
once on its way into the document, where images are kept by content hash.

Regression: fifty hashes on one blob share one copy; a hash naming a
missing blob is left out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
2026-10-28 22:45:45 +01:00
Filip StrajnarandClaude Opus 5.5 2d6638599d M3: scaled instances keep their scale when their main is rebuilt (#922)
The Scale tool (and `transform.scale`) scales a layer's children whatever
their constraints. After the edit, overrides are recorded by diffing the
changed layers, and a size or place that changed while an ancestor resized
is read as what constraints give, so it isn't an override. For a scaled
instance that left its layers' scaled size and points unrecorded: the next
rebuild of the instance (a layer added to its main, say) put a 14 px
icon's triangle back at the main's 24 px size. Strokes already kept their
scaled weight, as a separate override group.

`scale_node` now marks `size` and `position` overrides on the layers it
scales below the scaled layer when they are layers of an instance and the
value changed. Undo takes them back with the rest of the edit.

The report's own sequence (clearing effects on the component holding the
icon) no longer reproduces on main; this covers the rebuild path that
still did. Regressions: a scaled instance through value edits and a
rebuild of its main; one scaled with the tool, with undo; a scaled icon
nested in a component and in an instance of that component, through edits
of both mains.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
2026-10-28 22:45:32 +01:00
NetterSilverandClaude Opus 5.5 71571cccad Keep the right edge of right-aligned auto-width text as it grows
Fixes #1477

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:34:52 +01:00
Filip StrajnarandClaude Opus 5.5 62c69ba7ab M8: normalising after a command runs under the panic guard (part of #1085)
Session::execute ran a command under guard::run but normalised what it
changed afterwards, outside it: a panic in normalisation escaped execute
instead of becoming EngineError::Internal with the edit rolled back. Inside
a batch it unwound through Session::grouped, which then never restored the
undo limit it had lifted or ended the group. Normalising is now the last
step of the guarded result chain, so a panic there fails the command like
one in the command itself (and a batch step fails like any other failed
step); live text previews normalise under the guard too.

The other half of #1085, restoring grouped()'s state when its closure
panics, is left alone: #1419 rewrites that function.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
2026-10-28 22:11:31 +01:00
Filip StrajnarandClaude Opus 5.5 8d7c094224 M1: the stroke weight keeps room for its value beside the variable button (#1452)
The stroke's position dropdown took half the row and the weight field what
was left after the per-side and Advanced buttons: about 40 pt at the
panel's default width, so the variable button sat on the weight's icon and
hid its value, and the row ran 3 pt past the panel's edge. The position
and the weight now share what the buttons leave: the panel's two columns
for layers without per-side strokes, and about 66 pt each with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
2026-10-28 22:10:28 +01:00
Filip StrajnarandClaude Opus 5.5 d5ce10f0be M8: repair and library manifests check membership by set lookups (#1078)
Repair re-listed unlisted pages with a scan of the page list per page, and
reading a published library kept manifest styles, collections and variables
with a scan of the snapshot's lists per entry: a file with many pages or a
long manifest made both quadratic. Both now look ids up in a set built once.
validate() already indexed its parent/child edges and page list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
2026-10-28 22:10:28 +01:00
Dylan M. TaylorandClaude 2af5e232d5 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1475)
Resolve host-tuple to the host triple, pass --target to cargo build when
CARGO_BUILD_TARGET is set, and skip the --version smoke run for cross
builds (they cannot run on the x86_64 VM); cross builds get an ELF check
instead. Archive names carry the arch from the target triple.

Co-authored-by: Claude <[email protected]>
2026-10-28 15:48:52 -04:00
Denis Gomes 57dcff6d9a M7: #1360 review: cap the clipboard PNG's bytes before copying it
The pixel cap reads only the PNG's header, so a small image followed by a
large tail of data passed it and was copied and base64-encoded whole.
MAX_CLIPBOARD_PNG_BYTES (256 MB, the same cap as design data read from the
clipboard) is checked on the NSData length before the copy on macOS, and
again in the paste path so every Services implementation is held to it.
A PNG past the cap is treated like an unreadable one: paste falls back to
the decoded image.
2026-10-28 14:39:35 -04:00
Brandon ThomasandClaude Opus 5.5 ef9721c5b9 Release 0.12.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:37:05 -04:00
Brandon ThomasandClaude Opus 5.5 cb227c9715 Web: raise the wasm size gate to 25 MiB - 512 KiB
The 0.12.0 release's Web job failed at 25,319,327 bytes against the 24 MiB (25,165,824-byte) gate. opt-level z, fat LTO and wasm-opt -Oz are already in place and extra wasm-opt passes save only ~20 KB, so the gate moves to 25,690,112 bytes, still under Cloudflare's 25 MiB per-file cap. The wasm grew ~2 MB in three days (egui 0.37, new features); bringing it back down is follow-up work.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:36:52 -04:00
Dylan M. Taylor 32e8fd0b29 Grant network access so update checks can reach GitHub (#1471) 2026-10-28 13:20:45 -04:00
Dylan M. Taylor fa1c6cba42 Merge pull request #1403 from dylanmtaylor/sonar/enforce-clippy-standards
Enforce Rust coding standards via Clippy
2026-10-28 12:50:16 -04:00
Brandon ThomasandClaude Opus 5.5 08204fd7c2 Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:18:40 -04:00
Dylan M. Taylor 94cf3f3fd5 test(text): derive wrap threshold from matching glyph metrics 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 763c3a8dc4 refactor(svg): split node rendering and text setup 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 8df2e4f8dc refactor(render): pass context through export helpers 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 95a456f391 refactor(server): simplify storage and collaboration helpers 2026-10-28 16:00:36 +00:00
Dylan M. Taylor ffb4fc7e3e refactor(ui): simplify app and presentation handlers 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 252d909427 refactor(ui): simplify widgets and dialogs 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 927ceb0638 refactor(ui): simplify panels and menus 2026-10-28 16:00:36 +00:00
Dylan M. Taylor e98b97de2b refactor(engine): simplify session and test helpers 2026-10-28 16:00:36 +00:00
Dylan M. Taylor d87f9effe1 refactor(engine): simplify asset and code generation helpers 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 5e16683963 refactor(engine): simplify canvas and board handlers 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 23f57c8660 refactor(engine): simplify command handling for strict clippy 2026-10-28 16:00:36 +00:00
Dylan M. Taylor bafda992a7 docs: clarify crate lint placement 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 4268bd859f refactor(fig): split mapping and lint test helpers 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 6ebcf6b511 refactor(text): split shaping and layout finishing 2026-10-28 16:00:36 +00:00
Dylan M. Taylor c618e402fa refactor(format): replace wildcard imports 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 99afdd99ab docs: document workspace lint inheritance 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 8591d44909 refactor(doc): declare node property dependencies 2026-10-28 16:00:36 +00:00
Dylan M. Taylor 10bb163626 fix(xtask): apply workspace lint policy 2026-10-28 16:00:35 +00:00
Dylan M. Taylor e1a223bb1f refactor(layout): split flow layout phases 2026-10-28 16:00:35 +00:00
Dylan M. Taylor c420613efa chore(clippy): enforce strict workspace lint policy 2026-10-28 16:00:35 +00:00
watchakorn-18kandClaude Sonnet 5.5 38ab0eaa6b docs(ui): document the right-click label pick and its tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015wxxFg3gana4zaUKnyirTC
2026-10-28 22:27:29 +07:00
watchakorn-18kandClaude Sonnet 5.5 df6062d7fb fix(ui): right-click on a frame's name label picks that frame
The context menu looked only at what is under the pointer on the page, so a name label drawn over
its section selected the section. Copy link to selection then gave every frame in that section the
section's link. Labels now win, as they do on a press.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015wxxFg3gana4zaUKnyirTC
2026-10-28 22:14:06 +07:00
NetterSilverandClaude Haiku 5.5 877ac1fd5e Reject non-page ids in page.duplicate
Found while testing crates/engine/src/cmd/page.rs:96 (page.duplicate, `let src = id(p, "id").unwrap_or(s.page()?)`)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 15:38:32 +01:00
samhyunandClaude Fable 5.1 3773a60801 M8: the browser's wait after a preview never exceeds the render it may save
With a fixed 120 ms wait, a page whose full renders take 15–100 ms was held coarse for 120 ms
after a key zoom or a page switch, where before it was refined in the next frame. The wait is
now the last full render's time when that is shorter than `SETTLE_MS`: a wait never costs more
than the render it may save, and on a heavy page (seconds a render) it is 120 ms as before.

Also from review: the test that the wait counts from the preview no longer times a preview
render (a margin of a few milliseconds under load); a sleep between the request and the poll
stands in for the render, with the whole settle as margin. `Inline::in_flight` says that a
preview's wait counts.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 22:53:07 +09:00
phonedu 8e6b3bf449 M12: MCP comment feedback and live change events
Comments become the task queue for an attached or headless MCP client: comment.prompt returns a bounded Markdown brief, the Comments panel gains Copy for AI, and the MCP server exposes fix_comments, watch_comments, figcraft://comments and wait_for_changes. Mutating comment.* commands append to a bounded in-memory event log; the control channel's events.poll long-polls it without the UI thread, and a successful batch commits its events only if the whole batch lands. No model, network dependency, asset or file-format change.
2026-10-28 21:42:31 +08:00
samhyunandClaude Fable 5.1 8c951810ab M8: a test for same-zoom requests before a zoom refines; the gaps status line
From the review of #1468: a pan or an edit at the new zoom before its full render has run
renders from scratch too, so it shows a preview and restarts the wait instead of running a
blocking full render between it and the next step; once refined, requests at that zoom build
on the kept image and are refined at once. The new test pins both down. The status line of
`docs/gaps.md` now carries the date of the gap 7 entry.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 22:29:55 +09:00
samhyunandClaude Fable 5.1 f24a8312f7 M8: the browser refines a zoom only once it has paused
The browser renders on the UI thread. A zoom showed its coarse preview and then, in the very
next frame, ran the full render, so every wheel step of a continuous zoom had a blocking full
render between it and the next: on a heavy page each one took seconds, and the zoom stuttered
for as long as it went on.

The browser's worker (`Inline`) now refines a preview only once no newer request has come for
`SETTLE_MS` (120 ms) after the preview was shown: the zoom has paused. Each wheel step still
shows its preview at once, and the worker asks for a repaint when the wait is over. Pans and
edits have no preview and are refined at once, as before. The desktop has a render thread of
its own and is unchanged. A zoom passing through a scale also no longer spends a full render
there.

The wait counts from the preview being handed over, not from the request: rendering the
preview can itself take longer than the wait (the page is blocked meanwhile, so no newer
request could have come), and counted from the request the full render would follow in the
very next frame, as before.

Measured in Chrome on a synthetic page of 20 images, 60 shadowed rectangles and two
background blurs (a full render takes about 3.7 s, a preview about 250 ms), 20 wheel steps in
and 20 out, 50 ms apart, three runs each: the frame interval's p95 went from 3.7 s to 240 ms,
the frames over 100 ms from 79–80 (one full render per step) to 39–41 (one preview per step),
and the zoom took 10–12 s instead of 141 s. The one full render after the pause still takes as
long as before.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-28 22:10:27 +09:00
NetterSilverandClaude Haiku 5.5 2199dcb2da Reject non-numeric paddingHorizontal/paddingVertical in node.set
Found while testing crates/engine/src/cmd/props.rs:623-634 (the "paddingHorizontal" and "paddingVertical" arms)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 13:04:10 +00:00
hussain-hz 636096fad3 M10: add Arabic draft interface catalog
Translate 2508 interface entries from English, keep count-neutral plurals, and gate the web catalog on Arabic font readiness. Record AI assistance and pending native review.

AI-assisted-by: OpenAI Codex
2026-10-28 15:16:31 +03:00
Skyfuller1303 faa96926f4 M4: keep stroked whole-shape merges and bound region paints per file
Merging every region of one stroked shape is a no-op again: only fill
parts count, so a vector with several painted regions still merges.

The region paint import budget now covers the whole file instead of
resetting for each layer, so instances of a main with large region paint
stacks cannot multiply the work.
2026-10-28 11:59:53 +01:00
Skyfuller1303 c785e96b1c Merge remote-tracking branch 'origin/main' into feat/fig-vector-region-fills
# Conflicts:
#	crates/format/src/fig/export.rs
#	crates/format/src/fig/map.rs
2026-10-28 10:12:00 +01:00
Skyfuller1303 4b13e4e3cf M7: line the Home tab's divider up with the icon rail
The Home tab started at x=8 and was 44 wide, so its divider sat at x=52 while the icon rail below is 56 wide. Without inset window buttons the Home tab now ends at the rail's width (shared RAIL_WIDTH constant), so the divider continues the rail's edge. The inset (macOS) title bar keeps its 44-wide Home tab.
2026-10-28 10:00:02 +01:00
MortenHustedandClaude Opus 5.5 8fadd5d8e5 #1460: file.exportSvg's doc says which of file.export's options it takes
"(as file.export)" read as all of them, but the PDF and XML options are
refused there, so it now says they are file.export's SVG options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:46:27 +01:00
MortenHustedandClaude Opus 5.5 3567bef862 #1460: commands and tools that pass their params on list what they pass
file.placeImage runs image.place's code and file.exportSvg file.export's,
so their docs now list the names those read (width, height, target, mode;
scale or size and the export options). MCP's create_node passes points,
closed and select to node.create, and pointer and key read ctrl, so their
schemas list them too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:35:23 +01:00
Skyfuller1303 219a9ea0b1 M4: require FILL styles for vector region fill links 2026-10-28 09:29:22 +01:00
MortenHustedandClaude Opus 5.5 40b9288f70 M1: opening a file that is already open says when it changed on disk, and can read it again
`file.open` on a path already open in a tab switched to it and returned
its index, even when another process had changed the file since, so the
caller kept looking at the old copy with nothing saying so (#997). The
tab's disk stamp from #1330 (`DocState::on_disk`) now tells:

- the result says `stale: true` when the file on disk differs from what
  the tab read or last saved;
- `reload: true` reads the file again into the tab, as Revert to saved
  does (refused while the tab has unsaved changes or is in a live
  session), and the result says `reloaded: true`.

MCP's `open_file` takes `reload` and passes it on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:19:56 +01:00
MortenHustedandClaude Opus 5.5 27c11858e1 M0.9: MCP tools and batch steps refuse names they don't take (#1413)
`run_command {"command": "inspect.code", "args": {...}}` ran the command
without its params, on the selection. MCP tool arguments are now checked
against each tool's input schema (and the objects it describes inside,
such as a batch's steps; open objects like `params` take anything), and
a step key other than `command`, `params` and `as` is refused in
command.batch, figcraft-cli scripts and the MCP batch tool.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:06:59 +01:00
MortenHustedandClaude Opus 5.5 ff4fe00294 M0.9: commands refuse parameter names they don't take (#1413)
A misspelt parameter name was ignored and the command reported success,
answering as if the parameter hadn't been given (`comment.list
{"filterr": "open"}` listed resolved threads). `Session::execute` now
refuses names the command's params doc doesn't list, naming them and
what the command takes, before the command runs, so every path into a
command (CLI, batches, control channel, MCP, plugins, the UI) gets the
check.

The names come from the existing docs, parsed once with the registry:
each top-level item's first word and the words joined to it by `|`, `/`
or `+`, bracketed text and what follows `:`, `=` or `→` being
description, alternative groups (`{x, y} | {points}`) included. `ids`
and `id` are always taken (`params::targets` reads either); node.set,
whose doc is its props list, keeps its own check.

The junk sweep runs each junk object as given and cut down to the names
a command takes, so the junk values still reach the parsers. Tests that
passed names their command never read (comment.resolve's author,
collab.host's link, node.get's depth) pass only what it takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:06:58 +01:00
MortenHustedandClaude Opus 5.5 64e648ae7e M0.9: command docs list every parameter their commands read
Before commands can refuse names their docs don't list (#1413), the docs
have to list what the code reads. These are the top-level names the run
code (and every helper it hands the params to) reads that its doc left
out, all checked against the code; nothing changes behaviour:

- creation: node.create (characters, points, closed), frame.create,
  shape.*, text.create and vector.create (index, name, select),
  image.place (name)
- aliases the code already took: comment.* (id | link), annotation.add
  and annotation.edit (label | text), text.set (characters | text),
  text.listStyle (style | list), fill.add and stroke.add (paint | color),
  proto.interaction.add/set (durationMs | duration_ms, delayMs | delay_ms)
- options: file.export and export.update (contentsOnly,
  includeBoundingBox, svgOutlineText, svgSimplifyStroke), comment.next
  (frame, resolved), layout.suggest (direction), variable.create
  (description), vector.edit.widthPoint (side), collab.share (key),
  collab.manageFile (access, label, link, name), fill.pattern (visible,
  blend)
- docs whose shape hid a name: proto.interaction.add (a stray `}` closed
  the object before delayMs; dest was only in a trailer),
  proto.interaction.set (patch fields in a second object), collab.join
  (a top-level comma inside the link examples), pref.uiScale (commas in
  the value's description)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:06:33 +01:00
MortenHustedandClaude Opus 5.5 81769cbe74 #1458: test layoutMode beside the keys that tune it too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 08:51:02 +01:00
MortenHustedandClaude Opus 5.5 f98da27882 #1458: name the key that turns auto layout off
`{"layoutMode": "NONE", "wrap": true}` was refused with a message
naming `layout`, a key the call didn't have. The error now names the
key the call used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 08:49:34 +01:00
MortenHustedandClaude Opus 5.5 2ff69183d7 M7: CSS system font names draw in macOS's System Font
`system-ui`, `-apple-system`, `BlinkMacSystemFont` and
`.AppleSystemUIFont` join the names `apple_alias` resolves to the
installed System Font, as SF Pro and SF UI already do. SVG import keeps
a family the SVG lists when a font answers to its name, so on a Mac
`font-family="-apple-system, system-ui, sans-serif"` keeps
`-apple-system` and draws in System Font; elsewhere the generic
stand-ins apply as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 08:35:46 +01:00
MortenHustedandClaude Opus 5.5 b45c41a02c M2: auto layout settings in one call don't depend on how their keys are spelled
`apply_order` gave `layout` the rank of every other key, so the props
map's alphabetical order decided: `gap` applied before an object
`layout`, which then replaced the whole auto layout (the gap went back
to its default), and `padding` applied after `layout: null` and turned
auto layout back on.

`layout`/`layoutMode` now apply right after the geometry keys and
before the keys that tune them, and auto layout keys beside a `layout`
of null or "none" are refused, naming them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 08:35:33 +01:00
NetterSilverandClaude Opus 5.5 2d02f289ec Keep session joins working when a document has out-of-range ids
Fixes #1067

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 02:16:00 +01:00
Davit MatchakhelidzeandClaude Opus 5.5 a7ea510c65 i18n(ka): მხარდაუჭერელის in "Ignore unsupported"
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-28 05:11:34 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 610f40bd78 i18n(ka): Georgian for 76 strings added since the language landed
Click = დაწკაპეთ in the voting hint, as in the catalog's other 24 click hints.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-28 04:00:21 +04:00
aiongg 90054a89c4 Merge pull request #1399 from Jeremy8776/feat/install-scope-policy
Windows installation policy: FigCraft
2026-10-27 17:09:27 -04:00
aiongg 2ed7aee28d ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:59 +00:00
Guillermo Bueno fe7872708f M4: draw bounded dots for zero-length round dashes 2026-10-27 21:11:32 +01:00
Guillermo Bueno 54eced7ffb M6: address offline Penpot review regressions 2026-10-27 20:48:08 +01:00
Jeremy 738b218837 fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:08:54 +00:00
Filip Strajnar 6cc466a878 M6: credit Filip Strajnar for the FigJam import fix 2026-10-27 20:04:54 +01:00
Guillermo Bueno 7ea161f33f M6: merge main and isolate offline Penpot import/export 2026-10-27 18:59:49 +01:00
Filip Strajnar 4ea2d29754 M6: preserve FigJam fallback outlines and sticky bodies 2026-10-27 18:47:27 +01:00
aiongg 8b591f703d Merge pull request #1437 from Silvertree2010/perf/export-speed
Speed up raster export: faster blur and spread passes
2026-10-27 13:40:56 -04:00
NetterSilverandClaude Opus 5.5 adb1b43033 Speed up raster export: faster blur and spread passes
Shadows and layer blurs dominated PNG/JPG/WebP export time (gdb stack
sampling of the example files: about 45% in fx::box_blur_h, 13% in
fx::morph). Both now produce the same bytes, faster:

- fx::blur runs its three box passes per row while the row is in cache,
  works on whole pixels instead of bounds-checked bytes, skips fully
  transparent rows, transposes in cache-friendly column blocks, and
  splits rows and transposes across rayon on desktop for pixmaps of
  64K pixels and more, only when not already on a rayon worker (a band
  of render_view stays on its thread: no nested parallelism).
- fx::morph (shadow spread on layers that aren't one outline) uses the
  van Herk / Gil-Werman running max/min: constant work per pixel instead
  of 2r + 1.

Integer arithmetic and pass order are unchanged; new tests compare both
against the previous implementations byte for byte, including sizes on
the parallel path.

An ignored timing test exports every top-level frame of examples/ as
PNG, JPG and WebP at 2x through export_file:
cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture

Ryzen, 16 threads, Linux; median of 7 (min):
  PNG   2195 ms (2182) ->  992 ms (989)   -55%
  JPG   2880 ms (2560) -> 1550 ms (1535)  -46%
  WebP  1964 ms (1901) -> 1022 ms (1018)  -48%
  total 7035 ms (6677) -> 3567 ms (3555)  -49%
All 80 exported files (PNG, JPG, WebP, PDF, SVG) are byte-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:43:23 +01:00
Jeremy 2893518c84 fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:22 +00:00
aiongg bdb7e84606 fix(ui): dropdown menus grow back after a search with no matches (#1429)
* feat(ui-egui): font family menu keeps its search field and grows back after a filter clears

Repin craft-libs to the shared dropdown (craft-ui), move the font family menu to
craft_ui::combo::Menu with the search in a fixed header, and use craft_ui::ComboBox
for the credits sort. clippy bans egui::ComboBox.

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)

* fix(ui-egui): craft-ui on every target (wasm uses the shared ComboBox too)
2026-10-27 10:28:10 -04:00
Zaxaerith d4471376ca fix(engine): honor selected frame for file thumbnails (#1428) 2026-10-27 21:50:46 +08:00
Jeremy 8d1460c3c0 fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:01:18 +00:00
Jeremy 1288e40a53 fix(installer): address scope and ARM64 review feedback 2026-10-27 12:01:18 +00:00
Jeremy d17d3f4860 feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:01:18 +00:00
Muamer MustaficandCursor 1b545e91b2 M1: the release decides copy or move when Alt changed after the last move (#1385)
Co-authored-by: Cursor <[email protected]>
2026-10-27 12:45:45 +01:00
Muamer MustaficandCursor a02c4abefc M1: Alt pressed during a drag turns the move into a copy (#1385)
Co-authored-by: Cursor <[email protected]>
2026-10-27 12:21:44 +01:00
NetterSilverandClaude Haiku 5.5 9d4a3c6d86 Reject colour arrays containing non-numeric entries in color_of
Found while testing crates/engine/src/automation/params.rs:170 (color_of, Value::Array arm)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 11:15:17 +00:00
NetterSilverandClaude Haiku 5.5 3b78ca9558 Treat a % suffix on node.set opacity as a percentage
Found while testing crates/engine/src/cmd/props.rs:476-479 (the "opacity" arm), with crates/engine/src/automation/params.rs:14-20 (f64_of)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 11:57:38 +01:00
samhyunandClaude Fable 5.1 6b73c590f8 M9: a ping every keep-alive interval, whatever else the client sends
A viewer moving the pointer sends presence about 30 times a second, and every message sent put
the next ping off by the keep-alive interval, so no ping went out while presence did. The host
answers pings and nothing else to a client alone in a session (presence goes to the other peers),
so nothing was heard for the idle timeout (45 s): the client dropped a healthy connection and
reconnected, over and over. Seen in the browser build; the desktop app's guest has the same
timing (its reader times out the same way) and gets the same change.

Pings are now timed from the last ping, not from the last message sent: one goes out every 15 s
whatever else is on the wire. Regression test: 90 s of presence at 10 a second with the host
answering only the pings keeps the connection (it closed before the change).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-27 19:43:45 +09:00
0d47564737 M3: bind fill and stroke variables directly in layer props (#1000)
node.create and node.set take {"$var": variable id or name} for fill and stroke: an exact id
first, then an unambiguous name or Collection/name. The binding goes through the same code as
variable.bind, is applied after literal values and style links, replaces other bindings of that
paint's colour and clears its style link, all in one undo step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-27 12:55:48 +03:00
d21c9d4292 M8: keep undo entries after a failed batch undoes an earlier edit (#1410)
Session::grouped saved only the undo stack's length, so a failed group could drop the entries it
added but not bring back the ones an edit.undo inside it popped. It now keeps each document's undo
entries on entry, as it already did for redo, and restores them when the group fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-27 12:55:48 +03:00
d86a7ad9d9 M0.9: inspect layer trees with selected fields or a compact outline (#996)
node.get, the control channel's inspect methods and the MCP get_node / inspect_document tools
take fields: [node keys], applied at every level, or outline: true (id, name, type, characters
cut to 120 characters, mainComponentName, visible). Ids and structure are always kept, properties
that aren't selected aren't computed, and the default output is unchanged. Part of #996.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-27 12:55:47 +03:00
NetterSilverandClaude Haiku 5.5 f1264cf13e Reject '+' sign in NodeId::from_str
Found while testing crates/doc/src/lib.rs:64-71 (impl FromStr for NodeId), reached through crates/engine/src/automation/params.rs:78 (id_of)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 08:35:02 +00:00
NetterSilverandClaude Haiku 5.5 d3bde76baf Clamp Vector Drawable numbers to the f32 range so they stay finite
Found while testing crates/vectordrawable/src/xml.rs:22 (num), reached via stroke_attrs at :189

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 06:04:47 +00:00
Denis Gomes d94609d63f M7: #1360 review: narrower density match, decoded image when the PNG is unreadable
A density now counts only within 0.005 of a whole 2x-4x multiple of
72 dpi. That covers the rounding of pixels per metre (144 dpi is stored
as 5669 or 5670) and rejects nearby resolutions: 141, 143 and 145 dpi
were read as 2x under the old 0.05.

Paste falls back to the decoded clipboard image when the clipboard's PNG
can't be read, instead of pasting nothing.

Also the line on what the objc2 crates are for, and the ROADMAP row for
the new placing default.
2026-10-27 01:39:28 -04:00
Denis Gomes a497b99078 M7: place images at their own size, in points for 2x screenshots
A placed image was always fitted into 1000 px on its longer side, so a
screenshot of a 1440 px wide page came in at 1000 px and stopped lining
up with the design it was taken from. Figma places an image at its pixel
size and only scales down a side past 4096 px.

Placing now does the same, and reads the screen density a PNG records:
macOS screenshots carry 144 dpi (pHYs), so an 800 x 600 px capture of a
400 x 300 pt area comes in at 400 x 300, as in Figma. Only whole 2x-4x
multiples of 72 dpi count as a screen density; 300 dpi print scans and
other values keep their pixel size. The image data stays at full
resolution, so exports at 2x and up stay sharp.

On macOS, pasting reads the clipboard's public.png as is instead of the
decoded pixels arboard returns, which drop the density (and the colour
profile). Other platforms keep the decoded image.

Files dropped, Place image, paste and image.place all go through the
same path; an explicit width/height still wins.
2026-10-27 01:38:19 -04:00
Mert DedeandClaude Opus 5.5 9d7c435f6b #1408: match a Greek final sigma in node.find filters
Lowercasing a name ending in Σ gives ς, while a query Σ on its own lowercases to σ, so the
substring check missed it (CodeRabbit). Both sides now fold ς to σ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:33:28 +03:00
300a5f2f9d M0.9: let batches continue after failed steps (#1386)
command.batch and the MCP batch tool take onError: "abort" (the default, unchanged) or
"continue". In continue mode each step runs inside a rollback-only checkpoint split out of
Session::grouped: a failed step is rolled back on its own, later steps still run, and the result
is one {step, command, ok, result|error} object per step. What was applied is one undo step; a
reference to a failed step fails too; malformed batches are still rejected before any step runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-27 07:57:54 +03:00
57c1133a34 M0.9: find layer ids without changing the selection (#996)
node.find {root?, name?, type?, text?, component?, limit?} returns {ids, total} for the strict
descendants of root (or the current page) that match every filter given: name, text and main
component name as case-insensitive substrings, type as an exact type name. It changes neither the
selection, the page nor the undo history. Part of #996; fields/outline are left for later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-27 07:57:54 +03:00
8ae76d9b2a M8: bound attached MCP replies to 512 MiB (#1113)
The attached backend read each reply from the app's control port with read_line, which has no
limit. It now reads up to 512 MiB before the newline (an app window capture is at most 256 MiB as
base64) and stops at the first byte past that. An oversized or non-UTF-8 reply drops the
connection and is reported like a lost reply; the next call reconnects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: GPT-6.1 Sol <[email protected]>
Co-Authored-By: GPT-6 Astra <[email protected]>
2026-10-27 07:57:53 +03:00
叶林橦 5f7aea91f9 ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button
Brings #1210 onto main as it stands after #1019 and #1209, and keeps both:

- #1019's flyout caret keeps its 16 pt target, 2 pt clear of its tool (#988).
- #1209's narrow-window handling stays as it landed: the pill is never wider
  than the canvas less a margin and its buttons scroll inside it under a drag
  or the wheel, the help button lifts above it when they'd overlap, and the
  pill still clips to itself rather than floating over the panels.

The design itself, as #1210 proposed it:
- One grid for every state of the pill: 32 pt cells, 8 pt padding, 8 pt between
  top-level items (the divider included), a 1 pt divider as tall as the content,
  16 pt carets, 14 pt mode marks, and a 32 pt mode strip whose current mode is a
  plain inset with no outline.
- The Resources button goes; the left rail's Assets tab, Shift+I, Alt+2 and
  View > Panels > Assets remain the ways into the Assets panel (and the locale
  key, its test and the parity notes go with it).
- The vector edit and shape builder pills move onto the same grid, so a pill
  never changes its spacing as the tool changes.

main_width is now the same walk the pill draws, and a test walks it for the
design and board toolbars: the last item ends exactly PAD from the right edge.
That caught the board pill asking for 8 pt more than it drew.
2026-10-27 12:28:49 +08:00
叶林橦 59b9b90ee0 ui: show constraint guides for a selected layer in its frame
Split out of #932 (the 2 px outlines and missing frame radius handles there
move the canvas away from the reference tool, but the constraint-guides part
is good on its own — bflatastic, #issuecomment-6420566791). When a layer inside
a non-auto-layout frame is selected, blue dashed guides show how it is pinned:
a left/right/top/bottom constraint draws a line from the layer's edge midpoint
to the frame edge, and both-axis constraints draw both. A centre constraint
marks the layer itself with a short solid bar through its middle — horizontal,
vertical, or a cross when centred on both axes — rather than a guide reaching
the frame's centre.
2026-10-27 11:56:53 +08:00
叶林橦 27d5d99e56 M7: land Flutter code generation on main's code targets
main split `codegen.rs` into `dev/codegen.rs` (the shell: `CodeLang`,
`code_for`, `Ctx`, the shared helpers) and one module per native target
(`css`, `html`, `swift`, `compose`, `arkui`), and added Vue and ArkUI from
other PRs. Flutter arrives as the tenth target, in its own module:
`dev/codegen/flutter.rs`, wired from `codegen.rs` (`CodeLang::Flutter`, `dart`
as an alias, rem left to the web targets), with `flutter_definition` /
`flutter_type` / `flutter_default` beside main's `arkui_definition`.

Every language list in the tree is a union of the two sides now: the
`inspect.code` and `inspect.componentCode` specs, `codeConnect` (parse, import
line, expression, template, path checks, messages), the playground CSS/HTML
assets, the 21 UI catalogues (main added eight after this branch: bn, fi, it,
ka, nl, pl, th, vi, each with Flutter in its own conjunction) and the docs.
Flutter is an extra code target; nothing main gained is dropped.

The reviewer's absolute-child bug (#1382-1403: a child that ignores its auto
layout parent fell through to `Place::Flow`) is fixed in `flutter_children`:
flow and absolute children are split, the absolute ones go in a `Stack` around
the Row/Column at the parent's own box (so their offsets stay the parent's and
the padding moves onto the line instead of insetting them), the line fills that
Stack (`Positioned.fill`, so `Expanded` still has a bounded width), and only
flow children now decide `CrossAxisAlignment.stretch` and `Expanded` — the
absolute child no longer joins the line's spacing or loses its offsets.

`flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line`
covers both kinds of child in one auto-layout frame: a 50×20 flow child and a
30×30 absolute one asking for `fill` on both axes, in a parent with 4 pt
padding. It checks the offset, that the absolute child is out of the Row, that
no `Expanded`/stretch comes from it, and that the padding insets the line only.

ROADMAP and dev-mode-parity keep main's header (2026-10-26) and take this
branch's history row in date order (2026-10-16).

`cargo test`: engine 1391 pass, ui-egui 814 + 8 + 1 pass; fmt and clippy clean.
The one engine failure, `board_tables_tests::a_double_click…`, fails the same
way on a pristine `origin/main` here: this sandbox has no `craft-fonts`
checkout, so its text doesn't wrap and the row can't grow.
2026-10-27 11:30:27 +08:00
NetterSilverandClaude Haiku 5.5 aeaca0e1b9 Reject cornerRadius/padding/strokeSides arrays with non-numeric entries
Found while testing crates/engine/src/cmd/props.rs:165-175 (four, used at :506, :540 and :613)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 03:21:33 +00:00
NetterSilverandClaude Haiku 5.5 213cc75ff2 Fix looks_like_svg rejecting DOCTYPE with an internal subset
Found while testing crates/svg/src/import/mod.rs:723-740 (looks_like_svg, the `<!` arm using s.find('>'))

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 04:13:59 +01:00
叶林橦 0201cebefc M5: land the shared popover on main's colour and style pickers
main moved the colour picker into `widgets/colorpicker.rs` and gave it a fixed
area id (#976: `window_id()`), which the style picker looks up to tell a press
inside the picker from one outside. This merge keeps that id as the picker's
own area: the popover takes it as-is (`Popover::with_id`), so the lookup and the
#237 regression test keep working — the test now finds the picker by the id
instead of by its z-order.

The rest is the popover arriving on top of current main:

- the colour picker's `egui::Window` is the shared card: its 40 pt header —
  title 16 in, X 8 in from the right — is the popover's, and its body is
  unchanged, eyedropper included;
- the style picker's own area, frame and border are the same card on the dark
  surface; its title moved into the header, its tools row stays;
- presses in the Design panel and on the control that opened the picker are
  held out of the closing, so that control's own click toggles the picker;
- while the eyedropper samples (still picking, or a colour just picked) a press
  on the canvas is the pick, so it does not close the picker it picks for.

`right.rs` was split by main: its four swatch call sites are now in
`panels/right.rs` (page background), `panels/right/paints.rs` (a text range's
fill, a paint row) and `panels/right/effects.rs` (an effect's colour). egui 0.37
deprecates `Id::new` for `Id::unique`, which the new module uses.

`cargo test -p figcraft-ui-egui`: 824 + 8 + 1 pass; fmt and clippy clean.
2026-10-27 10:42:55 +08:00
叶林橦 3ab918dc52 M4: land the Design panel's grid on main's split panels
main split `right.rs`, `typography.rs` and `layout_panel.rs` into `panels/`
(the right panel's sections in `panels/right/{paints,stroke,effects,inspect,menu}`,
the Type section in `panels/typography/`). This merges that refactor with the
panel grid, so the grid's numbers now live where the code does:

- rows inset 16 from the left and 8 from the right (`block`, `sublabels`);
- two 88 pt columns with 8 pt gutters and a 24 pt button column at a row's end
  (`col_width`, the stroke's align/weight pair, the effect and grid rows);
- a row's two buttons are one group on the right edge, 24 each, 4 apart
  (`widgets::tight_end` in the paint, effect and grid rows);
- section headers: the last icon ends 8 pt from the edge, icons 28 apart;
- the right panel's frame keeps no stroke (a stroke counts as margin and took
  1 pt off each side); its edge is drawn in `right_panel`.

Widget text stays at 11 pt, the size the app we match uses: the merge keeps
main's type throughout (section titles 11 pt, now regular; captions 10 pt) and
changes geometry only. The Type section's size field follows the new column.

`cargo test -p figcraft-ui-egui`: 814 + 8 + 1 pass; fmt and clippy clean.
2026-10-27 10:13:28 +08:00
aiongg 43af145a12 Merge pull request #1394 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:30:55 -04:00
SusieeTheFemboyandClaude Opus 5.5 860bc13e79 M4: an emergency break can back up to right after the line's first letter (part of #1219)
The backward search for a safe break started at line_start + 2, so in a
three-letter run like กเม it skipped the only safe boundary and left the
line ending in a leading vowel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:14:05 +01:00
skylar taylor-barrick 695054520c Merge pull request #1395 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:35:59 -06:00
SusieeTheFemboy 1c09e8385e M4: Thai lines keep marks and leading vowels with their letter (part of #1219)
Thai, Lao, Khmer and Burmese write no spaces, so a paragraph has no break
opportunity and wraps where it overflows. That emergency break could start a
line with a letter's trailing mark or end one with a leading vowel, splitting
a syllable (and, when the break fell between the glyphs of one cluster, drawing
it twice).

- The break backs up to a boundary that keeps a mark with its letter and a
  leading vowel with the consonant after it; when the line's first character is
  in one, it carries the pair over to the next line instead. Lao leading vowels
  count too.
- It only breaks between grapheme clusters now, never between glyphs of one.
- Tests: a font-free unit test for the boundary rule, and a Thai wrapping test
  over 39 widths (skips without a Thai face).
Co-Authored-By: DeepSeek V4.1 Flash (opencode) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:55:27 +01:00
StrajnarFilip 01bc4741a1 M6: retain generated FigJam vectors and outer text fallbacks (#1341) 2026-10-26 22:56:23 +01:00
NetterSilverandClaude Haiku 5.5 576d8e228c Cap Sketch image members by compression ratio and total size
Found while testing crates/format/src/sketch/mod.rs:241-249 (Archive::image, MAX_IMAGE at :69), called once per distinct imageRef from crates/format/src/rest/mod.rs:951 (Ctx::image_key)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 22:46:33 +01:00
Filip Strajnar 4e650a1c01 M6: preserve FigJam sticky and shape content on import (#1341) 2026-10-26 21:52:09 +01:00
aiongg 60c671da66 docs: update source paths for the layout moves 2026-10-26 20:36:51 +00:00
aiongg 52d89286cf refactor(collab-net): move hub test files to hub/ 2026-10-26 20:36:51 +00:00
aiongg c9d6b356fc refactor(format): move fig/map test files to fig/map/ 2026-10-26 20:36:51 +00:00
aiongg e1e5b4bbe3 refactor(ui-egui): move widgets/style_picker test files to widgets/style_picker/ 2026-10-26 20:36:51 +00:00
aiongg 990742c32e refactor(ui-egui): move present test files to present/ 2026-10-26 20:36:51 +00:00
aiongg aca3643b18 refactor(ui-egui): move control test files to control/ 2026-10-26 20:36:51 +00:00
aiongg 8f2f93cd0b refactor(ui-egui): move canvas test files to canvas/ 2026-10-26 20:36:51 +00:00
aiongg d436103fca refactor(ui-egui): move i18n test files to i18n/ 2026-10-26 20:36:51 +00:00
aiongg a1308e1c1a refactor(ui-egui): move dialogs/credits test files to dialogs/credits/ 2026-10-26 20:36:51 +00:00
aiongg 9ca6e1a82f refactor(ui-egui): move bidi test files to bidi/ 2026-10-26 20:36:51 +00:00
aiongg 0330a7cc85 refactor(engine): move assets/components test files to assets/components/ 2026-10-26 20:36:51 +00:00
aiongg 2576e646a1 refactor(engine): move sharing/background test files to sharing/background/ 2026-10-26 20:36:51 +00:00
aiongg 7d70e0bfe5 refactor(engine): move assets/library test files to assets/library/ 2026-10-26 20:36:51 +00:00
aiongg 9c97cb6e17 refactor(engine): move assets/library_server test files to assets/library_server/ 2026-10-26 20:36:51 +00:00
aiongg 32bd9dc35a refactor(engine): move fonts test files to fonts/ 2026-10-26 20:36:51 +00:00
aiongg 3398ec3c8c refactor(engine): move automation/file_access test files to automation/file_access/ 2026-10-26 20:36:50 +00:00
aiongg f664a82093 refactor(engine): move assets/library_swap test files to assets/library_swap/ 2026-10-26 20:36:50 +00:00
aiongg 3102bffc90 refactor(server): split store.rs into store/ 2026-10-26 20:36:50 +00:00
aiongg a7a35a8322 refactor(collab-net): split hub.rs into hub/ 2026-10-26 20:36:50 +00:00
aiongg 064e6be65f refactor(format): split fig/export.rs into export/ 2026-10-26 20:36:50 +00:00
Skyfuller1303 7f707ecca3 M4: preserve inherited region paints and linked fill styles 2026-10-26 21:33:56 +01:00
aiongg 450675919f refactor(format): split fig/map.rs into map/ 2026-10-26 20:17:49 +00:00
aiongg 3b56a856be refactor(ui-egui): split app.rs into app/ 2026-10-26 20:17:49 +00:00
aiongg 5c29d184f4 refactor(ui-egui): split icons.rs into icons/ 2026-10-26 20:17:49 +00:00
aiongg 1d892d2e7b refactor(ui-egui): split panels/typography.rs into typography/ 2026-10-26 20:17:48 +00:00
aiongg f61032b3c9 refactor(ui-egui): split canvas.rs into canvas/ 2026-10-26 20:17:48 +00:00
aiongg 99edc6c815 refactor(ui-egui): split panels/right.rs into right/ 2026-10-26 20:17:48 +00:00
aiongg 6167d19e52 refactor(ui-egui): split present.rs into present/ 2026-10-26 20:17:48 +00:00
aiongg 2fded28028 refactor(ui-egui): split lib.rs into app.rs, services.rs, state.rs, clock.rs and open.rs 2026-10-26 20:17:48 +00:00
aiongg 65443020db refactor(ui-egui): split menus.rs into menus/ 2026-10-26 20:17:48 +00:00
aiongg d8de0fdea7 refactor(ui-egui): split dialogs.rs into dialogs/ 2026-10-26 20:17:48 +00:00
aiongg a9d93f8310 refactor(engine): split cmd/file.rs into file/ 2026-10-26 20:17:48 +00:00
aiongg 96423f5a6e refactor(engine): move cmd/file.rs zip_cap_tests to file/zip_cap_tests.rs 2026-10-26 20:17:48 +00:00
aiongg 13855cfabe refactor(engine): move cmd/file.rs staging_write_tests to file/staging_write_tests.rs 2026-10-26 20:17:48 +00:00
aiongg 47c807f209 refactor(engine): move cmd/file.rs rest_import_tests to file/rest_import_tests.rs 2026-10-26 20:17:48 +00:00
aiongg f0f1c2003d refactor(engine): move cmd/file.rs tab_order_tests to file/tab_order_tests.rs 2026-10-26 20:17:48 +00:00
aiongg 1e9e0a4e75 refactor(engine): split session.rs into session/ 2026-10-26 20:17:48 +00:00
aiongg c4bd0dd542 refactor(engine): move Session state, prefs, errors and methods out of lib.rs 2026-10-26 20:17:48 +00:00
aiongg 0b9fcb7f4e refactor(engine): split cmd/component.rs into component/ 2026-10-26 20:17:48 +00:00
aiongg cea90cf935 refactor(engine): split cmd/edit.rs into edit/ 2026-10-26 20:17:48 +00:00
aiongg 688eeccc7a refactor(engine): split boards/board.rs into board/ 2026-10-26 20:17:48 +00:00
aiongg ad4ba1ae9b refactor(engine): split dev/codegen.rs into codegen/ 2026-10-26 20:17:48 +00:00
aiongg c631450b0c refactor(engine): split sharing/live.rs into live/ 2026-10-26 20:17:48 +00:00
aiongg 0bc49705bd refactor(engine): split assets/components.rs into components/ 2026-10-26 20:17:48 +00:00
aiongg 2e7790b58b refactor(engine): split canvas/tools.rs into tools/ 2026-10-26 20:17:48 +00:00
aiongg 7d3978bedf refactor(ui-egui): move home_recents, online to home/ 2026-10-26 20:17:48 +00:00
aiongg 49ca56c3a4 refactor(ui-egui): move colorpicker, style_picker, field_math, font_preview, board_picker to widgets/ 2026-10-26 20:17:48 +00:00
aiongg 525d5cb6a1 refactor(ui-egui): move credits, missing_fonts, import_report, updates_ui, variables_panel, branch_ui, plugins_ui to dialogs/ 2026-10-26 20:17:48 +00:00
aiongg a0e33576d7 refactor(ui-egui): move layers, assets_panel, versions, right, component_panel, export_panel, layout_panel, section_panel, selection_colors, typography, variable_ui, vector_panel, scale_panel, frame_presets, list_drag, proto_panel, proto_editor, dev_mode, dev_compare, shortcuts_panel to panels/ 2026-10-26 20:17:48 +00:00
aiongg 3e23a0f884 refactor(ui-egui): move cursors, frame_labels, frame_stats, guides, handles, measure_overlay, proto_canvas, render_worker, selection_spacing, live_text, comments, board_bar, board_facilitate, widget_bar to canvas/ 2026-10-26 20:17:48 +00:00
aiongg e1b0ea2c1d refactor(engine): move batch, params, inspect, file_access, doc_urls to automation/ 2026-10-26 20:17:48 +00:00
aiongg 2cf15106e1 refactor(engine): move datafill, textfit, templates, synthetic, synthetic_ds to content/ 2026-10-26 20:17:48 +00:00
aiongg 53b04cc784 refactor(engine): move plugins, plugin_directory, widgets to extensions/ 2026-10-26 20:17:48 +00:00
aiongg fa8e917ede refactor(engine): move collab, live, online, branches, invite_links, background to sharing/ 2026-10-26 20:17:48 +00:00
aiongg 9452ec1ada refactor(engine): move annotations, code_connect, codegen, component_codegen, tailwind, devcompare, devinspect, dev_measurements, dev_resources to dev/ 2026-10-26 20:17:48 +00:00
aiongg e466f4a03a refactor(engine): move components, library, library_server, library_swap, style_vars, playground, token_roles to assets/ 2026-10-26 20:17:48 +00:00
aiongg a587d964c9 refactor(engine): move selection_colors, selection_reorder, selection_spacing, rename to selection/ 2026-10-26 20:17:47 +00:00
aiongg 094b01e501 refactor(engine): move tools, snap, distance, guides, drop, arc_handles, shape_handles, rotation_origin, al_keys, booleans, shapebuilder, textedit, textpreview, vectoredit to canvas/ 2026-10-26 20:17:47 +00:00
aiongg 5d1b826f7a refactor(engine): move board, board_table, board_templates to boards/ 2026-10-26 20:17:47 +00:00
aiongg 096b96c699 refactor(ui-egui): move present.rs inline tests to crates/ui-egui/src/present/tests.rs 2026-10-26 20:17:47 +00:00
aiongg a3179236f3 refactor(engine): move textedit.rs inline tests to crates/engine/src/textedit/tests.rs 2026-10-26 20:17:47 +00:00
aiongg e9fb6a533e refactor(format): move crates/format/src/sketch/mod.rs to crates/format/src/sketch.rs 2026-10-26 20:17:47 +00:00
aiongg 8d4a449769 refactor(format): move crates/format/src/rest/mod.rs to crates/format/src/rest.rs 2026-10-26 20:17:47 +00:00
aiongg 47ee566f3d refactor(format): move crates/format/src/fig/mod.rs to crates/format/src/fig.rs 2026-10-26 20:17:47 +00:00
aiongg a99248b296 refactor(svg): move crates/svg/src/import/mod.rs to crates/svg/src/import.rs 2026-10-26 20:17:47 +00:00
aiongg 71524803d1 refactor(engine): move crates/engine/src/templates/mod.rs to crates/engine/src/templates.rs 2026-10-26 20:17:47 +00:00
aiongg d853eee820 refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs 2026-10-26 20:17:47 +00:00
aiongg 47f1315ec2 test: scan source directories recursively 2026-10-26 20:17:47 +00:00
skylar 476feabfc1 ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:07 -06:00
Patrik LermonandClaude Opus 5.5 1147d3357f i18n(sv): translate 47 new strings
Brings the Swedish catalog up to date with upstream main (740d1173).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:08:43 +01:00
Skyfuller1303 a4076caaf4 Merge main and preserve vector region paints through path operations
Retain regional paint stacks and coordinate frames through simplify, offset, flatten, bucket normalization and Shape Builder. Preserve legacy fill semantics and synchronize importer warning catalogs. Verified with cargo xtask ci on Windows and Ubuntu/WSL, native UI screenshots, and CLI round-trip/export checks.
2026-10-26 19:50:00 +01:00
BFlat 740d117313 Merge pull request #827 from mateusvillain/feat/color-picker-lch-oklch
M1: LCH and OKLCH models in the colour picker
2026-10-27 01:27:07 +07:00
bflatastic 5e7cd52f6d Merge main into #827 2026-10-27 01:06:45 +07:00
BFlat cac54fd552 Merge pull request #840 from StrajnarFilip/feat/vector-drawable-export
M6: export as Android Vector Drawable (XML)
2026-10-27 01:06:21 +07:00
BFlat 740cc2a2c6 Merge pull request #726 from ijashuzain/feat/variables-dart-export
M6: export variables as Dart constants for Flutter
2026-10-27 00:53:10 +07:00
BFlat a93dd5ab3b Merge pull request #287 from Nael-Nathanael/perf/256-glass-cache
perf(render): keep glass shapes between renders, fix a glass seam (#256)
2026-10-27 00:52:56 +07:00
BFlat cac991334d Merge pull request #320 from tylerjenningsw/perf/zoom-preview-cold
perf(canvas): preview cold zoom and page transitions once they run long (M8)
2026-10-27 00:50:49 +07:00
BFlat 29c89f7d5b Merge pull request #650 from JimKnoxx/overlay-anchor-trigger
M5: overlay positions relative to the layer that opens them
2026-10-27 00:49:52 +07:00
BFlat 9fdd226509 Merge pull request #664 from phonedu/feat/vue-code-target
M6: a Vue code target (single-file components)
2026-10-27 00:48:53 +07:00
bflatastic c16fd68242 Merge main into #840 2026-10-27 00:38:47 +07:00
BFlat b6fc2fd1ab Merge pull request #1152 from ricardoavilesfoto-collab/pr/fig-export-for-figma
.fig export: files Figma imports, with component set properties
2026-10-27 00:35:31 +07:00
bflatastic 502098e02a #840: the status lines without the merge's conflict markers 2026-10-27 00:32:35 +07:00
bflatastic 2aae395310 Merge main into #840 2026-10-27 00:31:32 +07:00
bflatastic fd0f4d8b60 Merge main into #664
Main's class_name takes the node kind (#1372); Vue's slot names pass slot.
2026-10-27 00:30:42 +07:00
BFlat 83c7940293 Merge pull request #1384 from muamerETS/fix/1370-drag-unfilled-selection
M1: a selected unfilled shape drags over a filled layer stacked below it (#1370)
2026-10-27 00:25:59 +07:00
BFlat 7cb369db8c Merge pull request #1372 from lileeei/1235-transliterate-class-names
Transliterate non-ASCII layer names into code-export class names
2026-10-27 00:23:36 +07:00
bflatastic b3016f7089 Merge main into #1152 2026-10-27 00:14:07 +07:00
BFlat 8583588839 Merge pull request #1366 from Silvertree2010/fix/h2716-px-to-rem-conversion-rounds-to-2
Keep full precision when converting px to rem in code export
2026-10-27 00:13:42 +07:00
BFlat ad9abe7ce6 Merge pull request #1133 from g1obalworlds/fix-batch-rollback-closed-tabs
engine: restore tabs closed inside a failed command group
2026-10-27 00:13:37 +07:00
BFlat e8656be10e Merge pull request #824 from youngeuibae/page-api
web: window.figcraft, the control channel for the page
2026-10-27 00:13:31 +07:00
BFlat 5890784fe2 Merge pull request #1369 from mojtabajafarzadeh/fix/share-key-and-large-file-publish
follow-up: server publish timeout, key-to-server binding, and "Go to main component" in the context menu
2026-10-27 00:13:25 +07:00
BFlat 4b000eeddf Merge pull request #1371 from denisg0mes/crisp-magnified-images
M7: show a magnified image's pixels on the canvas
2026-10-27 00:13:20 +07:00
BFlat dd5b5c0dc0 Merge pull request #1374 from Ridzzz0Alam/fix/1330-save-disk-changes
fix: Issue #1330 Save no longer overwrites changes another app made to the open file
2026-10-27 00:13:15 +07:00
BFlat adea3a5550 Merge pull request #1053 from Silvertree2010/fix/h1274-title-case-leaves-a-word-lowerca
Capitalise title-case word after an opening apostrophe
2026-10-27 00:13:01 +07:00
bflatastic 632f2cef4b Merge main into #840
# Conflicts:
#	docs/roadmap.md
2026-10-27 00:12:27 +07:00
BFlat 4392fe50d3 Merge pull request #1373 from Ridzzz0Alam/fix/1338-instance-swap-scroll
fix: Issue #1338 an instance-swap property's default menu scrolls
2026-10-27 00:11:16 +07:00
bflatastic 15c6834114 Merge main into #1384 2026-10-27 00:10:34 +07:00
BFlat 58718d9a09 Merge pull request #959 from Silvertree2010/fix/h1129-saving-a-document-with-a-nan-num
Save NaN or infinite numbers as 0 so the file reopens
2026-10-27 00:09:53 +07:00
BFlat 23963491b3 Merge pull request #1378 from muamerETS/fix/open-issues
M4: truncated text fills its last line, keeps truncating when resized, and auto height takes max lines (#1375, #1376, #1377)
2026-10-27 00:09:39 +07:00
Jindřich ŠaršonandClaude Opus 5.5 3c71058177 M6: Sketch import (#1021 follow-up): review fixes
From a code review, each behaviour question checked on Sketch's own renders of
files built for it:

- background and motion blur radii are standard deviations too (doubled, like
  Gaussian ones); zoom blurs keep half their radius
- colour adjustments clamp FigCraft's contrast before working out the exposure,
  so the slope stays exact past FigCraft's ranges (brightness below -0.5 bent
  the whole curve)
- an image override applies after a layer style override on the same shape
- a bitmap's fills (its image under the style's fills) aren't linked to the
  shared fill style any more
- one read of a gradient's colorInterpolation; a dead stack condition removed

Checked and unchanged: symbols of a Sketch 63 file don't clip either, and
Sketch mixes OKLab gradients' alpha as the importer does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:55:56 +01:00
bflatastic 56ed786e0e #726: reuse codegen's kotlin_color for Dart colours 2026-10-26 23:54:09 +07:00
bflatastic 5a5175cc7e #726: write Dart numbers at full precision 2026-10-26 23:54:08 +07:00
bflatastic a683e51045 #726: Dart names never shadow Dart types (classes and constants) 2026-10-26 23:54:08 +07:00
bflatastic 8653597c3d #664: Vue layer trees import connected components in <script setup> 2026-10-26 23:53:30 +07:00
bflatastic 4176400e09 Merge main into #664
# Conflicts:
#	crates/engine/src/code_connect.rs
#	crates/engine/src/codegen.rs
#	crates/engine/src/component_codegen.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/playground.rs
#	crates/ui-egui/locales/bn.toml
#	crates/ui-egui/locales/cs.toml
#	crates/ui-egui/locales/de.toml
#	crates/ui-egui/locales/en.toml
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/locales/fi.toml
#	crates/ui-egui/locales/fr.toml
#	crates/ui-egui/locales/id.toml
#	crates/ui-egui/locales/ja.toml
#	crates/ui-egui/locales/ka.toml
#	crates/ui-egui/locales/ko.toml
#	crates/ui-egui/locales/nl.toml
#	crates/ui-egui/locales/pl.toml
#	crates/ui-egui/locales/pt-BR.toml
#	crates/ui-egui/locales/ru.toml
#	crates/ui-egui/locales/sv.toml
#	crates/ui-egui/locales/tr.toml
#	crates/ui-egui/locales/zh-Hans.toml
#	crates/ui-egui/src/dev_mode.rs
#	docs/dev-mode-parity.md
#	docs/gaps.md
#	docs/mcp.md
#	docs/target-app-parity.md
2026-10-26 23:52:51 +07:00
Jindřich ŠaršonandClaude Opus 5.5 267fc624ad M6: Sketch import (#1021 follow-up): match Sketch's renders rule by rule
Each rule was checked against Sketch's own renders (sketchtool) of files built
for the case, and on real Sketch 50, 63 and 2026 files:

- instances show a master's background only when it is included in both
  instances and exports
- symbols clip their layers only with clippingBehavior 1
- group and instance fills are tints only from Sketch 64 (format > 123)
- an outline mask's opacity fades the mask and what it clips together
- a bitmap's image lies under its style's fills
- image overrides reach a shape's top image fill
- stretching stacks fill their items across
- a layer blur's radius is Sketch's standard deviation (FigCraft radius x2)
- colour adjustments: contrast, then brightness added, mapped exactly onto
  exposure and contrast within FigCraft's ranges
- gradients mixed in OKLab or OKLCH become sRGB stops along the same colours
  (with an import note)
- auto-width texts an override made longer grow from their aligned edge
- shape group operands lose their own borders (since #1184 a stroke-only
  operand gives its stroke band, not its area)

Known limitations are listed in the file-format parity row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:43:10 +01:00
bflatastic ec12b7d5f3 #827: port the picker's new ids to egui 0.37 (Id::unique) 2026-10-26 23:42:11 +07:00
bflatastic 3beea93715 Merge main into #827
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/ui-egui/src/colorpicker.rs
#	docs/ui-parity.md
2026-10-26 23:38:20 +07:00
bflatastic 2a8db20d48 #726: drop the ROADMAP rows 2026-10-26 23:28:45 +07:00
bflatastic 6145409859 Merge main into #726
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/variables_io.rs
#	crates/engine/src/variables_io_tests.rs
#	docs/components-parity.md
#	docs/mcp.md
2026-10-26 23:28:15 +07:00
bflatastic bf9783f30f #650: mark relative overlay positions as an extra beyond the reference tool 2026-10-26 23:24:36 +07:00
bflatastic de826e14cf Merge main into #650
# Conflicts:
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/locales/pl.toml
#	docs/prototyping-parity.md
2026-10-26 23:24:22 +07:00
bflatastic c208ae442c #840: move the CLI and MCP XML export tests into tests/it 2026-10-26 23:23:08 +07:00
bflatastic e469cc8135 Merge main into #840
# Conflicts:
#	apps/figcraft-cli/src/files.rs
#	apps/figcraft-cli/src/main.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/locales/en.toml
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/src/export_panel.rs
#	crates/ui-egui/src/export_preview_tests.rs
#	docs/file-format-parity.md
#	docs/mcp.md
#	docs/roadmap.md
#	docs/ui-parity.md
2026-10-26 23:23:07 +07:00
bflatastic 0e8c1a0437 #1372: restore the blank line after the ROADMAP status line 2026-10-26 23:10:17 +07:00
bflatastic 1f1c8624c9 #824: window.figcraft is opt-in: installed only with a ?control query entry 2026-10-26 23:10:04 +07:00
bflatastic b87cc3ab47 #824: a request carries main's request gate (state), not the old cancelled flag 2026-10-26 23:06:38 +07:00
bflatastic 4c101fa549 #959: the test is named for what it checks: non-finite numbers are saved as zero 2026-10-26 23:05:53 +07:00
bflatastic 8186c4da3f #1369: Go to main component replaces Create component only when every selected layer is an instance 2026-10-26 23:05:40 +07:00
bflatastic 35108cf8dc #1369: the server key field shows a key only for the server it was typed for 2026-10-26 23:05:39 +07:00
bflatastic d3db02de9a #1378: max lines only while truncation is ENDING, with a REST test for auto height with truncation off 2026-10-26 23:04:38 +07:00
bflatastic b8f34b11c1 Merge main into #320 2026-10-26 23:03:01 +07:00
bflatastic daec0e75d6 Merge main into #287 2026-10-26 23:02:48 +07:00
bflatastic 153250fedf Merge main into #1152 2026-10-26 23:00:18 +07:00
bflatastic 5a9b509074 Merge main into #1366 2026-10-26 22:57:27 +07:00
bflatastic e6e4e9ab8a Merge main into #1053 2026-10-26 22:57:25 +07:00
bflatastic b90749b377 Merge main into #1133 2026-10-26 22:57:23 +07:00
bflatastic d5e3e68158 Merge main into #1372 2026-10-26 22:57:22 +07:00
bflatastic b9fc8f5113 Merge main into #824 2026-10-26 22:57:20 +07:00
bflatastic debf300211 Merge main into #959 2026-10-26 22:57:18 +07:00
bflatastic 089b0d8a04 Merge main into #1373 2026-10-26 22:57:17 +07:00
bflatastic 1427221d61 Merge main into #1369 2026-10-26 22:57:15 +07:00
bflatastic 2458de74f2 Merge main into #1371 2026-10-26 22:57:14 +07:00
bflatastic 66e0e0c629 Merge main into #1384 2026-10-26 22:57:13 +07:00
bflatastic 372727c571 Merge main into #1378 2026-10-26 22:57:12 +07:00
bflatastic badf1f5904 Merge main into #1374 2026-10-26 22:57:11 +07:00
BFlat 6ea44e936f Merge pull request #966 from arksky1/codex/korean-support-20261021
Add Korean support
2026-10-26 22:38:50 +07:00
BFlat 90f9697a0e Merge pull request #1309 from denisg0mes/macos-font-manager-fonts
M7: find fonts that macOS font managers activate outside the font folders
2026-10-26 22:33:42 +07:00
bflatastic 420e704eee #966: one Templates entry, and the drop hint names .sketch, as main's catalogs do 2026-10-26 22:25:10 +07:00
bflatastic e4a78279c0 Merge main into #966 2026-10-26 22:20:32 +07:00
bflatastic f89f656d16 #1309: the font-manager path test runs on Unix only (its paths aren't absolute on Windows) 2026-10-26 22:19:46 +07:00
bflatastic 0ef1156f1f Merge main into #1309 2026-10-26 22:17:30 +07:00
Muamer MustaficandCursor 45326b3b1a M1: a layer in the gap between selected layers still takes the press (#1370)
Co-authored-by: Cursor <[email protected]>
2026-10-26 14:13:36 +01:00
Muamer MustaficandCursor 2136ec20b1 M1: a selected unfilled shape drags over a filled layer stacked below it (#1370)
Co-authored-by: Cursor <[email protected]>
2026-10-26 14:02:23 +01:00
Muamer MustaficandCursor 398c5404f5 M4: a truncated last line's char range covers the chars it takes from the next line (#1375)
Co-authored-by: Cursor <[email protected]>
2026-10-26 13:27:25 +01:00
Muamer MustaficandCursor b6d3e002df M4: truncated text fills its last line, keeps truncating when resized, and auto height takes max lines (#1375, #1376, #1377)
- The last line shown runs on into the word wrapped after it, so the ellipsis
  fills the line ("Lorem consec…") instead of following the last whole word.
- An explicit height (node.set, transform.resize, a resize handle, fixed
  sizing) on truncating text keeps it truncating (TextData::fix_size).
- Max lines on auto-height text hugs up to that many lines and ends in an
  ellipsis; text.maxLines keeps auto height, Type settings show it as
  truncating, .fig export writes textTruncation and import keeps auto height.
- node.get reports maxLines; text.fit measures every line.

Co-authored-by: Cursor <[email protected]>
2026-10-26 13:14:51 +01:00
Ridzzz0Alam 0d7d9ebd00 M1: stamp the file before reading it, so a change made during the read isn't missed (#1330)
Open and Revert to saved recorded the file's size and modification time after reading it: a change written in between would be stamped as already loaded and a later save would replace it. Taking the stamp first makes such a change refuse the next save instead (CodeRabbit on #1374).
2026-10-26 11:46:23 +01:00
Ridzzz0Alam a648700fcc M1: Save no longer overwrites changes another app made to the open file (#1330)
With a file open in the desktop app, figcraft-cli could change it on disk;
the next Save (or auto-save) wrote the app's copy over it and said
"Saved", losing the CLI's changes.

Each tab now remembers its file's size and modification time when it
opens, reverts or saves it (the Stamp library files already use), and
Save and auto-save refuse while the file on disk differs, pointing to
Revert to saved or Save as. Scripts pass `file.save {overwrite: true}`.
Save as and saves to an explicit path are unchanged.
2026-10-26 11:04:01 +01:00
lileeei 1138b13f17 Merge remote-tracking branch 'origin/main' into 1235-transliterate-class-names
# Conflicts:
#	ROADMAP.md
#	crates/engine/Cargo.toml
2026-10-26 17:56:57 +08:00
Ridzzz0Alam acdc714537 M3: an instance-swap property's default menu scrolls (#1338)
On a main component's Properties row, the default of an instance-swap
property opened a menu of every component in the file at full height, so
in a file with many components (an icon library) the bottom ones ran off
the screen and couldn't be picked. The menu now scrolls past 320 pt like
the instance's swap menu, is at least 200 pt wide, ticks the current
default and is named for assistive technology.
2026-10-26 10:29:49 +01:00
Mojtaba JafarzadehandClaude c9f834f13c ui: drop an unused variable in the key-binding test (CI gate fix)
Co-Authored-By: Claude <[email protected]>
2026-10-26 11:35:35 +03:30
Mojtaba JafarzadehandClaude c73ff432cb ui: rustfmt the context-menu instance check (CI gate fix)
Co-Authored-By: Claude <[email protected]>
2026-10-26 11:18:48 +03:30
Mojtaba JafarzadehandClaude 65d270dd42 ui: context menu offers "Go to main component" for instances
Right-clicking an instance always showed "Create component" (and never "Go to
main component"), so an imported instance looked disconnected from its main
even though the link was intact. When the selection is an instance, replace
"Create component" with "Go to main component" in the context menu, matching
Figma. A regression test covers the menu contents for an instance.

Co-Authored-By: Claude <[email protected]>
2026-10-26 10:55:38 +03:30
Denis Gomes f0623ff89c M7: show a magnified image's pixels on the canvas
Zoomed in on an image, the canvas smoothed it with bicubic sampling, so
a screenshot under inspection went soft at every edge and its pixels
could not be told apart. Checking a screenshot of a live page against
its design up close needs the pixels as they are.

The canvas now samples an image paint at the nearest pixel once each
image pixel covers two or more device pixels. Below that the image stays
smooth: between 1x and 2x nearest sampling draws pixels one and two
device pixels wide side by side, which looks jagged rather than sharp.

Exports, thumbnails, PDF and SVG are unchanged: the switch is a render
option, magnified_image_pixels, that only the canvas sets, and the
retained renderer reuses a render only when it matches. Pixel preview
was not a fit: it rasterises the whole canvas at 1x or 2x, so vectors
and text go blocky along with the image.
2026-10-26 03:23:56 -04:00
BFlat 63d926876f M10: branches in live sessions (#1367)
Branches of a file in a live session: create, edit, review, update from
main and merge while the file is in a session. The branch stays with its
author and makes ids in their own client space far above the main file's;
merging is one synced edit and one undo step; peers' edits merge against
the branch's own base and conflicts resolve as offline; a view link still
refuses merging; a branch made outside the session that adds layers is
refused with a reason. Branch history entries for creation and updates
from main. Fuzz and junk tests extended to export settings, guides and the
live case. Docs: gap 17, collaboration parity, ROADMAP, mcp, protocol.
2026-10-26 13:57:24 +07:00
BFlat 8d60360173 M11: widgets take text input and a property menu (plugin API 4) (#1365)
* M8: widgets take text input and a property menu

Plugin API 4 (gap 6). A widget's text layer can be an editable field:
a double-click edits it like any text, and when editing ends the value
(cleaned, at most 1,000 characters) goes into the widget's state under
the field's name and its plugin redraws, as one undo step. A widget can
also carry a property menu of up to 8 actions, toggles and colour
choices, shown in a small bar over it while it's selected.

- widget.setText and widget.menu drive the same paths from scripts,
  the CLI and MCP; widget.insert/list also report fields and menu
- descriptions, values and stored menus are validated as untrusted;
  an API 3 plugin drawing a field or menu is refused, older hosts
  refuse API 4 manifests; no new host imports
- SDK reads the new events; the Counter example gains a label field
  and a menu (reset, steps of ten, a colour)
- tests for fields, menus, canvas editing, rollback, forged data and
  live sync; junk sweep extended; labels in every command catalog
- docs: plugins, mcp, gaps, target-app-parity, ROADMAP, roadmap

* M8: widget property menu on egui 0.37

Id::new and Ui::id are deprecated in egui 0.37: the widget bar's area uses Id::unique and its buttons' interaction ids come from ui.scope_id().

* M11: a double-click a board object or a widget field takes, in a function of its own (Sonar)
2026-10-26 13:31:53 +07:00
Mojtaba Jafarzadeh 6442a49d13 Merge latest upstream main 2026-10-26 09:58:01 +03:30
NetterSilverandClaude Haiku 5.5 ca6426b9a1 Keep full precision when converting px to rem in code export
Found while testing crates/engine/src/codegen.rs:122-150 (lengths_to_rem calls num, which rounds to 2 decimals, at :112)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:24:14 +00:00
Mojtaba JafarzadehandClaude c826c74e6e ui: bind the share server key to its server
The Share popover's server key was stored without its server, so changing the
server in Preferences would send the previous server's key to the new one.
Remember the key with the server it was typed for and only send it when the
addresses match. A regression test covers the mismatch.

Co-Authored-By: Claude <[email protected]>
2026-10-26 09:39:21 +03:30
BFlat 948358eefa Docs: README screenshots of Boards and Dev Mode measurements (#1252) 2026-10-26 13:09:03 +07:00
Mojtaba JafarzadehandClaude 2f5286a387 collab-net: server keeps publish uploads alive with the reply timeout
A publish uploads a whole file; a large project on a slow link takes the
server a while to receive and answer. But the server's one-shot request
connections (manage, hosting, library, plugins) used the short request_idle
as their read timeout, so a slow upload could be cut off mid-processing. The
manage/hosting connections that carry uploads now wait the longer
request_reply_timeout (as the client already did), keeping the short idle
for the other one-shot requests and sessions.

Co-Authored-By: Claude <[email protected]>
2026-10-26 09:31:26 +03:30
BFlat d3782fdc43 M12: board templates and tables (#1363)
* M12: board templates and tables

Five built-in board templates (retrospective, brainstorm, weekly planner,
flow chart, kanban board) placed as ordinary board objects in one undo step
(board.template, board.templates) from a toolbar picker with pictures, and
board tables made of auto layout frames (board.table, board.tableEdit; up to
50 x 20), typed into with a double-click and edited by row and column from
the bar over the selection. Docs: boards-parity, gap 24, ROADMAP, mcp.

* M12: board templates picker on egui 0.37

Id::new and Ui::id are deprecated in egui 0.37: the templates picker's area uses Id::unique, its buttons' interaction ids come from ui.scope_id(), and its test reads the board bar's area by Id::unique.
2026-10-26 12:52:39 +07:00
BFlat bfd6e5f83a Merge pull request #1359 from Silvertree2010/fix/h2744-color-to-css-rounds-alpha-to-2-d
Keep 8-bit alpha when printing colours with Color::to_css
2026-10-26 12:51:22 +07:00
BFlat ad2fc0c7cb Merge pull request #1362 from peda-cos/fix/srv9-fsync-files-dir
fix: fsync the files directory after creating a new files folder
2026-10-26 12:43:17 +07:00
Pedro Monteiro 11cf6fb12a fix: fsync the files directory after creating a new files folder (#1093) 2026-10-26 02:34:04 -03:00
BFlat 4fad29a66e M12: board timer and voting sessions (#1361)
* M12: board timer and voting

A countdown everyone on a board sees (board.timer, board.timerState), synced as its end time or the
time left while paused, and voting sessions (board.vote, board.voteResults): votes per person, on
everything, stickies or the selection; clicks vote, your badge takes a vote back; your own votes
show while it runs, everyone's with a ranked list once it ends. Timer and vote sync as document
properties and ballots as one entry per voter; neither is an undo step. A pill at the top right
of the board, new Timer, Pause and Vote icons, labels and strings in every catalog, docs.

* M12: board timer and voting on egui 0.37

Id::new is deprecated in egui 0.37: the timer and voting area and its memory ids use Id::unique.

* M12: board.vote's start and ballot changes in functions of their own (Sonar)
2026-10-26 12:28:59 +07:00
BFlat 26291aff2c M12: boards dot grid, re-attaching connector ends, curved connectors (#1249)
* M12: boards dot grid, re-attaching connector ends, curved connectors

Board pages draw a dot grid in the canvas render, after the page colour
and under the layers (figcraft_render::Dots), thinned out when zoomed out
and coloured from the theme's ink against the page. A selected connector
shows its ends as handles; dragging one onto an object re-attaches it,
onto empty canvas leaves it free (board.reattach, one undo step, also
over MCP). Connectors can be curved, and elbows go around the objects
they join when the short way would cross them.

* M12: boards dot grid: blending a dot in a function of its own (Sonar)
2026-10-26 11:52:16 +07:00
NetterSilverandClaude Haiku 5.5 eb73f021c6 Keep 8-bit alpha when printing colours with Color::to_css
Found while testing crates/color/src/lib.rs:198 (Color::to_css)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 05:37:27 +01:00
Denis Gomes 5e32057bdd M7: font manager scan: drain Core Text's autoreleased objects; core-text 22
Review on #1309 pointed out that core-text 20.1's get_descriptors retains
the array it gets under the create rule a second time, so every scan kept
one array of font descriptors alive. Checking that turned up a larger leak:
Core Text autoreleases objects while it builds the collection, and the scan
runs where no autorelease pool is active (the app's background scan
thread, the CLI, the MCP server), so nothing ever drained them.

Measured with `figcraft-cli run` of N `text.rescanFonts` steps on macOS 15
(peak memory footprint):

| | 10 rescans | 200 rescans |
|---|---|---|
| main, without this PR | 221 MB | 220 MB |
| this PR before the fix | 228 MB | 796 MB |
| with the fix | 226 MB | 225 MB |

A standalone Swift loop over the same Core Text calls grows from 9 MB to
759 MB over 200 scans without a pool and stays at 20 MB inside one, so the
pool is what matters; the core-text bump removes the over-retain on top.

- The query runs inside objc2::rc::autoreleasepool. objc2 0.6 is already
  in the lockfile (winit's), and the function is safe, so the workspace's
  unsafe_code = "forbid" still holds.
- core-text goes to =22.0.0, the first release that takes ownership of the
  array correctly. It brings core-foundation 0.10, core-graphics 0.25 and
  core-graphics-types 0.2 beside the older versions other crates use. 22.0
  moved framework linking behind a `link` feature that is on by default,
  so with default features off it is named explicitly.

A rescan takes about 0.2 s instead of 0.08 s on that Mac (266 more font
files are indexed); it only runs when asked for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 7f6a3a4e46922d737b8053c7cd5968a02a558ae3)
2026-10-26 00:36:38 -04:00
Denis Gomes f1b41ffeaa M7: find fonts that macOS font managers activate outside the font folders
Font managers such as RightFont, FontBase and Typeface keep fonts in their
own library folders and activate them through Core Text, without copying
them into /Library/Fonts or ~/Library/Fonts. Every other Mac app lists
these fonts; FigCraft walks only the font folders, so it didn't, and a
file using one of them drew in a fallback font.

On macOS the scan now also asks Core Text for the file of every available
font and indexes the files that lie outside the walked folders. All three
scans (the first, the on-demand one and text.rescanFonts) share this, so a
font activated while the app runs is found by a rescan like a newly
installed one.

Core Text needs unsafe FFI, which the workspace forbids, so the query goes
through the safe wrappers of the core-text crate, pinned at =20.1.0. That
version builds on the core-foundation 0.9, core-graphics 0.23 and
foreign-types 0.5 the lockfile already has: core-text is the only new
package. The call sits inside catch_unwind, so anything unexpected from
Core Text leaves the scan with the font folders alone. Other platforms are
unchanged.

On a Mac with RightFont, 15 families from its library are now listed
(Geist, JetBrains Mono, Clash Display and others) that weren't before. A
full font listing went from 0.06 s to 0.20 s on that machine.

Tests: the filter that keeps files outside the font folders once, indexing
a font file found outside them, and (macOS only) that Core Text lists the
system's font files. cargo test --workspace, cargo clippy -p figcraft-text
--all-targets -D warnings and cargo fmt --check pass on macOS 15. CI
builds on Linux only, so the macOS path is checked locally.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 5699e7abe6e5994e526051774ec92aadddb0550c)
2026-10-26 00:36:21 -04:00
BFlat 33061d92fc Merge pull request #1287 from watchakorn-18k/thai-ui-fonts-and-catalog
M10: Thai UI fonts and a Thai draft catalog
2026-10-26 11:31:01 +07:00
BFlat edef2e2cde Merge pull request #1294 from AganzeFelicite/fix/bundled-font-installed-styles-1246
M7: offer the installed styles of an embedded font family (#1246)
2026-10-26 11:25:55 +07:00
lileeei aba296c6b8 M6: transliterate non-ASCII layer names into code-export class names (#1235)
class_name runs layer names through deunicode (new dependency: pure Rust
tables, zero deps, BSD-3-Clause, the slug crate's base) before its ASCII
filter, so Korean, Cyrillic, Latin-diacritic and CJK names become
readable ASCII classes in HTML/CSS/React/Leptos output, component roots
and data-* variant attributes: 로그인 화면 → rogeuin-hwamyeon, кнопка →
knopka, über → uber, 按钮 → an-niu (before, every one came out layer-,
layer--2, ...). Symbols-only names take the node's kind (.text, .frame)
instead of layer-, the 40-character cut lands on a word boundary, and a
leading digit keeps the layer- prefix. SwiftUI/Compose type names are
unchanged (their own sanitizer); ASCII-only names are byte-identical.
html_codegen_tests updated and extended.
2026-10-26 12:23:06 +08:00
bflatastic 245a8cca0d #1287: the Thai code-connect hint names ArkUI too 2026-10-26 11:16:51 +07:00
bflatastic b6d7d0a893 Merge main into #1287
# Conflicts:
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n_tests.rs
#	docs/localization-parity.md
#	docs/translating.md
2026-10-26 11:14:49 +07:00
bflatastic 573b43d645 #1294: its font test names no PostScript name, a field #1021 added 2026-10-26 11:13:14 +07:00
bflatastic 5ee983ef63 Merge main into #1294
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-26 11:12:25 +07:00
BFlat 8e79ffc3d7 Merge pull request #1021 from sarsonj/feat/sketch-import
M6: .sketch import (Sketch 48 and newer)
2026-10-26 11:10:16 +07:00
BFlat d203f8fdcf Merge pull request #698 from Dubblbindong/firma/fix/plugin-permission-to-save-a-file-86
feat(plugin): add saveFile permission with host-owned save dialog
2026-10-26 10:55:11 +07:00
BFlat 3a641026ad Merge pull request #1202 from culiulemon/main
Vector editing, property copy/paste, dev-mode assets, ArkUI codegen and shape-builder fixes
2026-10-26 10:53:13 +07:00
bflatastic 27d1089847 Merge main into #1021
# Conflicts:
#	ROADMAP.md
#	docs/target-app-parity.md
2026-10-26 10:43:34 +07:00
BFlat b107ddf882 Merge pull request #990 from midasdf/jp/kinsoku-jis
M4: CJK kinsoku completed to JIS X 4051
2026-10-26 10:41:08 +07:00
BFlat bcde334043 Merge pull request #1314 from denisg0mes/keep-images-past-budget
M7: don't save over a file whose images past the budget weren't loaded
2026-10-26 10:41:03 +07:00
bflatastic 1b946a247f Merge main into #698
# Conflicts:
#	README.md
#	crates/ui-egui/locales/fr.toml
#	crates/ui-egui/locales/sv.toml
2026-10-26 10:37:14 +07:00
bflatastic 5bed282751 #1202: the Italian and Vietnamese code-connect hints name ArkUI too 2026-10-26 10:36:00 +07:00
bflatastic f0385ef089 Merge main into #1202 2026-10-26 10:34:05 +07:00
bflatastic 8ffc5e57e8 #1021: the Vietnamese drop hint names .sketch too 2026-10-26 10:33:06 +07:00
bflatastic 76b5c8da6b Merge main into #1021 2026-10-26 10:31:59 +07:00
BFlat 9e993c6a77 Merge pull request #1026 from sonth87/i18n-vi
i18n: add Vietnamese (vi) interface localization
2026-10-26 10:31:47 +07:00
bflatastic 7177f1c042 Merge main into #1021
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-26 10:29:24 +07:00
BFlat 4b33cc17cb Merge pull request #1299 from mojtabajafarzadeh/fix/share-key-and-large-file-publish
ui: server key field in the Share popover (plus a longer publish reply timeout)
2026-10-26 10:27:36 +07:00
BFlat 660140ddc1 Merge pull request #984 from kler/swedish-ui-2026-10-19
i18n(sv): translate 38 new strings, use the agreed terms in 93
2026-10-26 10:25:56 +07:00
BFlat f93cdc769e Merge pull request #1340 from Silvertree2010/fix/1331-bug-report-figcraft-a-failed-bat
Name the failing step in batch command errors
2026-10-26 10:25:50 +07:00
BFlat 8b470eceaa Merge pull request #1275 from Ridzzz0Alam/fix/1062-render-bounds-depth
fix: Issue (#1062) render bounds stop at the depth the renderer draws.
2026-10-26 10:25:45 +07:00
BFlat f229d91ff2 Merge pull request #1243 from SusieeTheLinuxUser/fix/1239-wayland-clipboard
UI: Route text clipboard writes through egui (#1239)
2026-10-26 10:25:41 +07:00
bflatastic f291f89fbd Merge main into #990
# Conflicts:
#	ROADMAP.md
2026-10-26 10:24:45 +07:00
BFlat b2ac04bdf8 Merge pull request #1357 from storytold/land/1017
M9: make graphics-startup assertions independent of UI language (#1017)
2026-10-26 10:24:00 +07:00
bflatastic 02f15fa483 #1021: the Italian drop hint names .sketch too 2026-10-26 10:23:48 +07:00
BFlat f93deb965b Merge pull request #661 from AganzeFelicite/fix/adobe-fonts-macos-298
M7: find fonts activated through Adobe Fonts on macOS (#298)
2026-10-26 10:23:17 +07:00
bflatastic 6c4bb00c9e Merge main into #1021
# Conflicts:
#	crates/text/src/fontdb_tests.rs
2026-10-26 10:20:43 +07:00
BFlat e03ebccece Merge pull request #1209 from aryann2611/mobile-layout
M6: mobile layout (phones and portrait tablets)
2026-10-26 10:20:07 +07:00
BFlat 9df8075aeb Merge pull request #1019 from muamerETS/fix/988-toolbar-flyout-carets
M7: toolbar flyout carets get a wider target clear of their tool (#988)
2026-10-26 10:19:53 +07:00
bflatastic d0a165efaa Merge main into #1026
# Conflicts:
#	docs/translating.md
2026-10-26 10:15:28 +07:00
BFlat 3a845b9717 Merge pull request #703 from timrrrr/codex/cmd-resize-image-crop
M1: crop image fills on Command/Ctrl resize
2026-10-26 10:15:04 +07:00
BFlat 8a89d5c20e Merge pull request #607 from MMexicann/fix/outline-stroke-rings
M4: preserve stroke rings when outlining shapes
2026-10-26 10:14:58 +07:00
BFlat 5eefcfaaf6 Merge pull request #623 from ahmedhosnypro/fix/system-theme-linux
Fix the system theme mode always drawing light on Linux and the BSDs
2026-10-26 10:14:53 +07:00
BFlat 6713b0eae3 Merge pull request #921 from YarilaFox/fix/paste-image-into-selected-shape
M7: paste images into selected shapes
2026-10-26 10:14:48 +07:00
BFlat 3a9fed2c6a Merge pull request #681 from leandrodslv/i18n/fr-native-review
i18n(fr): translate the missing strings and review the draft catalog
2026-10-26 10:14:43 +07:00
BFlat 0e7cffb5e7 Merge pull request #1217 from Salah1St/feat/default-font
M7: FIGCRAFT_DEFAULT_FONT names the font new text starts in
2026-10-26 10:14:38 +07:00
BFlat 17596c6996 Merge pull request #1216 from Salah1St/fix/thai-ui-fallback
M7: Noto Sans Thai as a UI fallback when the build embeds it
2026-10-26 10:14:33 +07:00
BFlat 44a21e9b91 Merge pull request #1224 from CosmoNetinfo/add-italian-language
Add Italian (it) interface language
2026-10-26 10:14:10 +07:00
BFlat fea6a52c7d Merge pull request #1215 from Salah1St/fix/thai-text-fallback
M4: Thai faces in the text fallback list
2026-10-26 10:13:56 +07:00
bflatastic d9aa3eda87 Merge main into #1019
# Conflicts:
#	crates/ui-egui/src/toolbar.rs
2026-10-26 10:06:44 +07:00
bflatastic 1aed3d4915 Merge main into #990
# Conflicts:
#	ROADMAP.md
2026-10-26 10:03:33 +07:00
bflatastic 7c25399e4a Merge main into #661
# Conflicts:
#	ROADMAP.md
2026-10-26 10:03:15 +07:00
bflatastic b6c582396b #1287: drop the "Contents only" key main renamed 2026-10-26 10:01:26 +07:00
bflatastic 99435b72d7 Merge main into #1287
# Conflicts:
#	docs/translating.md
2026-10-26 10:01:26 +07:00
bflatastic 454ec26c11 #1299: the server key field's id uses egui 0.37's Id::unique 2026-10-26 09:41:10 +07:00
watchakorn-18kandClaude Sonnet 5.5 c7669d9d6d M10: cover Thai in the no-plural-forms test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015wxxFg3gana4zaUKnyirTC
2026-10-26 09:40:43 +07:00
bflatastic 3b56f18e8e Merge main into #1202
# Conflicts:
#	crates/engine/src/codegen.rs
#	docs/mcp.md
2026-10-26 09:21:24 +07:00
bflatastic eb16585ec2 Merge main into #698 2026-10-26 09:20:18 +07:00
bflatastic a41e725a15 Merge main into #966 2026-10-26 09:20:15 +07:00
bflatastic 27f3bf6730 Merge main into #703 2026-10-26 09:20:12 +07:00
bflatastic c6d43e7903 Merge main into #607 2026-10-26 09:20:09 +07:00
bflatastic 42ac4a50d2 Merge main into #623 2026-10-26 09:20:06 +07:00
bflatastic 0dd8bf591e Merge main into #921 2026-10-26 09:20:03 +07:00
bflatastic 5a9a45795a Merge main into #1209 2026-10-26 09:19:57 +07:00
bflatastic 6fe1618ed4 Merge main into #1017 2026-10-26 09:19:54 +07:00
bflatastic 2ca0ed9787 Merge main into #681 2026-10-26 09:19:51 +07:00
bflatastic a35dbf184f Merge main into #1217 2026-10-26 09:19:49 +07:00
bflatastic c2ccb7bb2c Merge main into #1216 2026-10-26 09:19:45 +07:00
bflatastic 83d664f26c Merge main into #1026 2026-10-26 09:19:42 +07:00
bflatastic 0f64dd280d Merge main into #1224 2026-10-26 09:19:39 +07:00
bflatastic 5cdd41a79f Merge main into #1215 2026-10-26 09:19:37 +07:00
bflatastic ec2ad1094d Merge main into #1294
# Conflicts:
#	ROADMAP.md
2026-10-26 09:16:31 +07:00
bflatastic 994ddfc47e #1299: the server key field shows dots, as a password field does 2026-10-26 09:15:55 +07:00
bflatastic a288fac725 Merge main into #1299 2026-10-26 09:15:54 +07:00
bflatastic 3bd905fbdf Merge main into #1243
# Conflicts:
#	apps/figcraft/src/services.rs
2026-10-26 09:15:53 +07:00
bflatastic 2fd59da54c Merge main into #1275
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-26 09:15:49 +07:00
bflatastic 8d52baa4de #984: Swedish joining a live session reads ansluta till 2026-10-26 09:15:43 +07:00
bflatastic 2a85a09cf9 Merge main into #984 2026-10-26 09:15:42 +07:00
bflatastic 6f1999c248 #1340: a failed step keeps its kind of error; only its message names the step 2026-10-26 09:14:25 +07:00
bflatastic c4a98342a6 Merge main into #1340
# Conflicts:
#	crates/engine/src/batch_step_ref_tests.rs
#	crates/engine/src/lib.rs
2026-10-26 09:14:13 +07:00
bflatastic 7a1b9d8414 #1021: a named style's optical size follows the text size only for .sketch imports
Existing text keeps the named instance's optical size, so documents render as before; the
Sketch import stores the size as the text's opsz value instead.
2026-10-26 09:12:40 +07:00
bflatastic 07a654959a Merge main into #1021
# Conflicts:
#	docs/file-format-parity.md
2026-10-26 09:12:39 +07:00
bflatastic df8edc6b8c #1314: refuse saving over the file only when save_to has a path (main's download has none) 2026-10-26 09:12:21 +07:00
bflatastic 1ec74401e6 Merge main into #1314 2026-10-26 09:11:52 +07:00
BFlat abc5105111 Merge pull request #1297 from Silvertree2010/fix/h2089-arrowhead-cap-disappears-when-an
Skip zero-length segments when finding arrowhead direction
2026-10-26 09:05:13 +07:00
BFlat 1172cfa444 Merge pull request #1324 from Silvertree2010/fix/h2487-end-arrowhead-missing-on-an-open
Fix missing end arrowhead when a closed subpath follows an open one
2026-10-26 09:05:08 +07:00
BFlat a2d4886b59 Merge pull request #1329 from Silvertree2010/fix/h2481-scale-tool-does-not-scale-per-po
Scale per-point corner radius of vector layers in transform.scale
2026-10-26 09:05:02 +07:00
BFlat f7d60ef460 Merge pull request #1315 from Silvertree2010/fix/h2365-scale-tool-transform-scale-does
Scale the path of text on a path in transform.scale
2026-10-26 09:04:57 +07:00
BFlat 4a48a70910 Merge pull request #1350 from dotCooCoo/fix/png-row-export-options
Docs: the PNG row names ignore overlapping layers and where include bounding box is offered
2026-10-26 09:04:50 +07:00
BFlat 49c2fa1263 Merge pull request #1339 from devius/ka-short-labels
i18n(ka): shorter Georgian for 13 labels that were cut off
2026-10-26 09:04:45 +07:00
BFlat 4fce30ceab Merge pull request #1346 from storytold/codex/egui-037-fig-followup-20261025
Keep synthetic Copy link commands inside the clipboard fence
2026-10-26 09:04:40 +07:00
BFlat 09d2d96588 Merge pull request #1353 from storytold/fix/sonar-new-code-refactor
refactor: clear SonarCloud new-code issues (S3776, S107) and exclude generated coverage tree
2026-10-26 09:04:36 +07:00
BFlat 3de687fe7c Merge pull request #1011 from lucasmartins-ai/feat/tailwind-output
Dev Mode: React + Tailwind page code and Tailwind theme export
2026-10-26 09:04:31 +07:00
BFlat 570348072e Merge pull request #1337 from Silvertree2010/fix/1336-bug-report-figcraft-images-with
Scale down images past 16384 px a side instead of drawing nothing
2026-10-26 09:04:25 +07:00
BFlat 7c16f311bd Merge pull request #1278 from Ridzzz0Alam/fix/1060-dash-count
fix: Issue #1060 Dash patterns too fine for their path draw solid instead of running out of memory
2026-10-26 09:04:21 +07:00
BFlat fdea151735 Merge pull request #1349 from dotCooCoo/fix/controller-buttons-wait-for-link-notice
M5: controller buttons wait while the link notice shows
2026-10-26 09:04:16 +07:00
BFlat 3c9fee8dbd build: one craft-libs rev for every craft-libs crate (495e597) (#1354) 2026-10-26 08:59:52 +07:00
BFlat 42d7c3c45f Merge pull request #1355 from storytold/fix/r3-followups
Review fixes for #1184, #919 and #1134
2026-10-26 08:59:44 +07:00
bflatastic 234a66458a #1026: Vietnamese drops "Contents only", which the export settings no longer use 2026-10-26 08:53:17 +07:00
bflatastic 4a07c6a485 #1224: Italian drops "Contents only", which the export settings no longer use 2026-10-26 08:53:00 +07:00
bflatastic 82ddbc8908 #1202: port the paint and effect row menus and the Inspect assets to egui 0.37 2026-10-26 08:45:41 +07:00
bflatastic d54186c9bf #1209: port the phone layout to egui 0.37 2026-10-26 08:44:25 +07:00
bflatastic 9c57ba1beb Merge main into #1202 2026-10-26 08:43:07 +07:00
bflatastic fa6c1bd3f4 Merge main into #1209 2026-10-26 08:41:46 +07:00
bflatastic 30ea80d541 Merge main into #1026 2026-10-26 08:41:31 +07:00
bflatastic 50a2d08087 Merge main into #1224 2026-10-26 08:41:28 +07:00
bflatastic b162ef884c #1134: the server memory note becomes a revision row and a line in docs/server.md
The paragraph above the ROADMAP stage line moves into the revision history; docs/server-memory-retention.md
is folded into the compaction bullet of docs/server.md.
2026-10-26 08:36:05 +07:00
bflatastic 08ce40fd88 #1134: name the snapshot checksum's place SNAP_HEADER - 4, not 13 2026-10-26 08:35:22 +07:00
bflatastic 3921556427 #919: the windows_slim_errors flag applies to every Windows GNU target, matching the compile check 2026-10-26 08:35:21 +07:00
bflatastic f124d907bc #1184: boolean operands use the rounded per-side strokes too
One side_strokes helper (square or rounded, as #1044 paints them) serves stroke_area and the boolean
operand outline, instead of a second, square-only copy; test for a rounded per-side border.
2026-10-26 08:35:20 +07:00
BFlat 57af418419 Merge pull request #1134 from F3R9/pr/server-memory-retention
M9: avoid unnecessary snapshot rewrites and duplicate buffers
2026-10-26 08:33:59 +07:00
BFlat 23b443978e Merge pull request #919 from YarilaFox/fix/windows-gnu-a11y-crash
M7: avoid Windows GNU UI Automation crash
2026-10-26 08:33:45 +07:00
BFlat 8b8eea2561 Merge pull request #1184 from saturngod/fix/boolean-stroke-outlines
Fix stroke-only boolean operand outlines
2026-10-26 08:33:31 +07:00
BFlat b3f06f6eca Merge pull request #1163 from switch87/fix/linux-symbol-fallback
M9: fall back to the Noto symbol and math fonts on Linux (#1156)
2026-10-26 08:33:05 +07:00
BFlat 2b23477544 Merge pull request #1035 from Nael-Nathanael/fix/752-alias-walk
M7: missing fonts: follow each font variable's aliases once (#752)
2026-10-26 08:32:52 +07:00
BFlat df1bdd0e7c Merge pull request #1010 from Ridzzz0Alam/fix/969-web-save-download
Web build: Save, Save as and Export arrive as downloads
2026-10-26 08:32:39 +07:00
BFlat c853432360 Merge pull request #989 from Ridzzz0Alam/fix/981-fig-layer-cap
Large `.fig` files keep their layers and their library links (#981)
2026-10-26 08:32:26 +07:00
BFlat c5b914ead0 Merge pull request #951 from Hamza0101/fix/fig-note-counts
fig import: a note says how much of the file it covers (#467)
2026-10-26 08:32:09 +07:00
BFlat 378ac56286 Merge pull request #1003 from kkilchrist/feat-994-batch-step-refs
command.batch: named step references (`as` / `$ref`) and paths in `field`
2026-10-26 08:31:53 +07:00
Dylan M. Taylor 831fa599f1 Revert "ci(sonar): exclude the generated coverage-archive tree from analysis"
This reverts commit 887676207a.
2026-10-25 21:25:39 -04:00
Dylan M. Taylor 887676207a ci(sonar): exclude the generated coverage-archive tree from analysis
CI unpacks a coverage archive containing a build-generated locales.rs (the
i18n table from crates/ui-egui/build.rs) into coverage-archive/. Scanning it
made almost all new code duplicated (new_duplicated_lines_density 67.8%).
2026-10-25 21:11:43 -04:00
Dylan M. Taylor 7dc5d3b377 refactor(mcp): group with_marker's cut sizes into a struct (SonarCloud S107)
The depth and wire-size numbers describing a cut subtree are now one Cut
value, so with_marker takes 5 parameters instead of 8. Output is unchanged.
2026-10-25 21:11:43 -04:00
Dylan M. Taylor 4fc0a39304 refactor(ui-egui): split style text_properties into helpers (SonarCloud S3776)
Each logical section of the text style editor (sample, font menu, style and
size row, line height and letter spacing, and the two menus) is its own
helper. UI calls keep their order; no behaviour change.
2026-10-25 21:11:43 -04:00
aiongg 8ae2e206fb chore(deps): safe dependency refresh (#1352) 2026-10-25 20:48:41 -04:00
dotCooCoo 1bac7367ba Docs: the PNG row's include bounding box applies to any layer from the commands
The Export section offers include bounding box for text layers, and
file.export and export.update take it for any layer.
2026-10-25 17:34:16 -07:00
dotCooCoo 7820825ea1 M5: controller buttons pressed with no frame shown are dropped
A controller button pressed while the player has no frame to show,
for example after the frame is deleted, no longer plays the
prototype's gamepad trigger once the frame comes back.
2026-10-25 17:09:48 -07:00
Denis Gomes 03f4e6e070 M7: images-past-budget guard: hard links, and script output over its input
Two gaps CodeRabbit found in the guard:

- `same_file` compared paths, plain and resolved. A second hard link to
  the file is a different path to the same data, so `convert`,
  `run --out` and Save As to the link passed the guard and truncated
  the file. It now asks the file system (same-file: device and inode on
  Unix, volume and file index on Windows). same-file was already in the
  lock through walkdir.

- `run --in X --out X` checked the tab the script left active. A script
  that ends on `file.duplicate` or `file.new` leaves a tab with no count
  of images left in X, so X was written over. The count is now taken
  when X is opened and checked against `--out` whatever tab is active;
  the active tab's own file is still checked as before.

The script check is a separate function so it can be tested without a
file past the 4 GiB budget.
2026-10-25 18:41:58 -04:00
Denis GomesandClaude Opus 5.5 c305e23ab4 M7: don't save over a file whose images past the budget weren't loaded
A native file whose images together pass IMAGE_BUDGET (4 GiB, or 20,000
images) opens without the images past it. They are intact in the file, but
saving it again wrote the archive from the images in memory, so every
save, autosave or `convert` onto itself deleted them for good. A 7.6 GiB
`.fig` converted to a 7.3 GiB `.figcraft` reopened with 1,312 of its 2,337
images: one save would have dropped the other 1,025.

Loading now tells images left out for the budget apart from damaged ones
(`Loaded::images_past_budget`). A tab opened or reverted from such a file
remembers the file, and saving over it is refused with a message that says
how many images would be lost and to save under another name; saving a copy
elsewhere works, and the original keeps its images. Auto-save skips the
tab instead of retrying and failing every minute. `figcraft-cli convert`
and `run --in X --out X` refuse to write over their input the same way.
The same file is matched by its resolved path, so `./a.figcraft` and
`a.figcraft` count as one (`file_access::same_file`).

The note for each image left out for the budget now says it stays in the
file, which can't be saved over without losing it. Damaged and missing
images keep the old wording: they can't be kept, and saving drops them as
before. The doc comment on report_lost_images said saving always drops
the images it reports, which is no longer true for budget ones, so it went.

This stops the data loss; it doesn't make such files editable in place.
Loading images on demand would, and is discussed in the issue this links.

Tests: the count and the note for both budgets; saving over the file
refused with the file unchanged, Save As then working, and saving back over
the original refused again; a snapshot encoded in the background refused
too; a tab without images left out saving as before. Checked by hand on the
7.3 GiB file with file.save, file.saveAs, convert and run --out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:31:47 -04:00
dotCooCoo 2581b0bfd9 Docs: the PNG row names ignore overlapping layers and include bounding box for text layers
The PNG row of docs/file-format-parity.md listed the export settings as
"contents only" and "include bounding box (the layer's own box)". Since
#1307 the Export section labels the first option Ignore overlapping
layers and shows Include bounding box for text layers. The row now uses
those names, says that ignore overlapping layers is on by default, and
gives include bounding box for text layers, as the help page lists
them. The status line gets the new date and topic, and the revision
history gets a row; the percentages and hours are unchanged.
2026-10-25 15:27:32 -07:00
Dylan M. Taylor d27061036f build: pin craft-update to craft-libs main (495e597) (#1348)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:52:27 -04:00
Dylan M. TaylorandCodebuff 0fb1f143cc build: pin craft-update to craft-libs main (495e597) (#1347)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <[email protected]>
2026-10-25 17:37:10 -04:00
Jindřich ŠaršonandClaude Opus 5.5 6258ad2786 M6: Sketch import: merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:23:40 +01:00
dotCooCoo 84d782c85b M5: controller buttons wait while the link notice shows
A controller button pressed while the notice before a website link
shows no longer plays the prototype's gamepad triggers, as keys do not.
Buttons play again once the notice closes.
2026-10-25 14:19:01 -07:00
Dylan M. Taylor 106ef69351 feat: startup update prompt modal and direct self-updating (#1328)
* feat: startup update prompt modal and direct self-updating

The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status window. The check at start is opt-in for the desktop app, an install holds back while documents are unsaved, and the Windows update helper runs before argument parsing.

Ref: storytold/craft-libs#319

* fix(updates): re-add the update hooks to the 0.37 frame loop

* fix(updates): address review feedback

Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.

* fix(updates): show the prompt on Home and presenter frames and close the application, not one window

* fix(updates): add the update strings to the English catalog
2026-10-25 16:23:00 -04:00
skylartaylor b7a0d892d7 M14: cover synthetic Copy link clipboard isolation 2026-10-25 20:39:43 +01:00
skylartaylor abfdaa2436 M-egui037: retain one explicit session switch in the qualified regression 2026-10-25 20:14:50 +01:00
skylartaylor 48bea15abd M-egui037: base clipboard follow-up on the published squash merge 2026-10-25 20:14:42 +01:00
skylartaylor 985b9c1525 M-egui037: preserve clipboard fencing across the published integration 2026-10-25 20:14:25 +01:00
236f3185d6 Migrate FigCraft to egui 0.37 (#1312)
* E21: require release font coverage and stabilize capture deadline tests

* E21: separate capture, IME and field-save regression phases

* M0: correct the migration qualification reference

* M-egui037: document input lifecycle and UI regression contracts

* M0: correct egui 0.37 status docs and stabilize join-code regression

---------

Co-authored-by: Dylan M. Taylor <[email protected]>
Co-authored-by: Codebuff <[email protected]>
2026-10-25 15:04:39 -04:00
Dylan M. Taylor fc41889916 Merge origin/main into the egui 0.37 migration
Reconcile upstream: keep both roadmap entries, main's Copy as submenu marker and
copy-link toast, and the migration's explicit join-order switch in the live
session test.

Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-25 14:49:38 -04:00
NetterSilverandClaude Haiku 5.5 0a4e70fb97 Name the failing step in batch command errors
Fixes #1331

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 18:23:37 +00:00
Patrik LermonandClaude Opus 5.5 408b77e9ec i18n(sv): translate 38 new strings, use the agreed terms in 93
Brings the Swedish catalog up to date with upstream main (35da7b7c).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:17:45 +01:00
Davit MatchakhelidzeandClaude Opus 5.5 35cea3ef8c i18n(ka): shorter wording for labels that were cut off
Decided in the native-speaker review of the screens (all-t01).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-25 22:13:52 +04:00
NetterSilverandClaude Opus 5.5 d7da3f1bb1 Scale down images past 16384 px a side instead of drawing nothing
Fixes #1336

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:01:22 +00:00
skylar taylor-barrick c413651984 egui-037: integrate current main and preserve migration fixes 2026-10-25 18:11:22 +01:00
Guillermo Bueno Vargas ff365be661 Merge branch 'main' into feat/penpot-import-export 2026-10-25 17:42:30 +01:00
skylar taylor-barrick 4eb5224f02 M0: correct egui 0.37 status docs and stabilize join-code regression 2026-10-25 17:13:00 +01:00
Brandon Thomas 3fc005c7fe Merge pull request #1295 from Silvertree2010/fix/h2091-inserting-text-at-the-boundary-o
Keep style runs disjoint when inserting text at a run boundary
2026-10-25 12:11:24 -04:00
Brandon Thomas e1d23d8480 Merge pull request #944 from Silvertree2010/fix/h884-color-from-hex-accepts-several-l
Reject multiple leading '#' in Color::from_hex
2026-10-25 12:11:18 -04:00
Brandon Thomas f53001cd93 Merge pull request #931 from lukasz-sklad/community/polish-native-review
i18n(pl): native review, catch-up and terminology fixes
2026-10-25 12:11:13 -04:00
Brandon Thomas f215fc4f95 Merge pull request #954 from MMexicann/fix/cli-option-terminator
M0.9: allow literal dash-prefixed CLI filenames
2026-10-25 12:11:08 -04:00
Brandon Thomas 8436737129 Merge pull request #1310 from dotCooCoo/fix/collab-net-loopback-tests
M9: the hello deadline refuses a connection even when a read times out early
2026-10-25 12:11:03 -04:00
Brandon Thomas 0761a0e07a Merge pull request #1303 from marcoarnulfo/fix/dialog-last-folder
Dialogs start in the folder of the last file picked
2026-10-25 12:10:58 -04:00
Brandon Thomas 31b857433d Merge pull request #1242 from SusieeTheLinuxUser/fix/1220-thai-backspace
Text: Backspace removes Thai and other marks one at a time (#1220)
2026-10-25 12:10:52 -04:00
Brandon Thomas 610fe374c7 Merge pull request #1218 from Salah1St/fix/justify-zwsp
M4: justified text doesn't widen U+200B
2026-10-25 12:10:47 -04:00
Brandon Thomas 15aeff572b Merge pull request #1291 from watchakorn-18k/context-menu-copy-link
M8: Copy link to selection in the layer context menu
2026-10-25 12:10:42 -04:00
Brandon Thomas dfbba05318 Merge pull request #1144 from Silvertree2010/fix/1139-bug-report-figcraft-macos-contex
Keep context menu open after a same-frame trackpad tap
2026-10-25 12:10:37 -04:00
Brandon Thomas 97191e20c7 Merge pull request #1251 from storytold/fix/hover-lines-leave
Canvas: hover distances go when the pointer leaves the canvas
2026-10-25 12:10:32 -04:00
Brandon Thomas 3ec1197a8e Merge pull request #1276 from Ridzzz0Alam/fix/1150-section-labels
fix: Issue #1150 a section's title draws above the labels of frames inside it (#1150)
2026-10-25 12:10:27 -04:00
Brandon Thomas 01c17983fb Merge pull request #1326 from Silvertree2010/fix/h2485-number-field-rejects-a-unit-type
Accept a unit typed after a space in number fields
2026-10-25 12:10:21 -04:00
Brandon Thomas ff056176f8 Merge pull request #1323 from Silvertree2010/fix/h2483-component-code-uses-js-strict-mo
Rename component parameters that are strict-mode reserved words
2026-10-25 12:10:15 -04:00
Brandon Thomas 4b27ae4c77 Merge pull request #1317 from Silvertree2010/fix/h2376-svg-export-rounds-the-scale-of-a
Keep significant digits in SVG matrix scale coefficients
2026-10-25 12:10:09 -04:00
Brandon Thomas 132fdba12f Merge pull request #1296 from Silvertree2010/fix/h2093-code-export-prints-any-whole-num
Fix code export printing whole numbers of 2^63 or more as i64::MAX
2026-10-25 12:10:05 -04:00
Brandon Thomas 7df4ba4900 Merge pull request #1282 from Silvertree2010/fix/h1985-distribute-spacing-measures-the
Measure distribute span to the group's outermost edge
2026-10-25 12:10:00 -04:00
Brandon Thomas 1b8e65fb01 Merge pull request #1274 from Silvertree2010/fix/h1924-non-finite-percent-strings-accep
Reject non-finite percent strings for lineHeight and letterSpacing
2026-10-25 12:09:54 -04:00
Brandon Thomas 8cbdff697f Merge remote-tracking branch 'origin/main' into fix-1295
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-25 11:56:28 -04:00
Brandon Thomas 44a2091b42 Merge remote-tracking branch 'origin/main' into fix-944
# Conflicts:
#	crates/color/src/tests.rs
2026-10-25 11:56:28 -04:00
midasdf b3242d8eaf Merge origin/main into jp/kinsoku-jis 2026-10-26 00:28:52 +09:00
skylar taylor-barrick 69dc49e2d3 M-egui037: reconcile current main and document hidden-frame deadlines 2026-10-25 16:05:48 +01:00
NetterSilverandClaude Haiku 5.5 000c0e3d4e Scale per-point corner radius of vector layers in transform.scale
Found while testing crates/engine/src/cmd/arrange.rs:~560 (scale_node, the Vector branch calling VectorNetwork::scale); crates/doc/src/vector.rs:631 (VectorNetwork::scale)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:22:29 +00:00
skylar taylor-barrick b751271c2a M-egui037: document input lifecycle and UI regression contracts 2026-10-25 14:52:12 +01:00
Brandon ThomasandClaude Opus 5.5 d4a303d470 Release 0.11.2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:44:44 -04:00
Brandon ThomasandClaude Opus 5.5 149f59cd14 Web: build the browser wasm with opt-level z to stay under the 24 MiB gate
The 0.11.1 release's Web job failed: the wasm was 25,248,590 bytes, over packaging/web/package.sh's 25,165,824-byte limit. opt-level z brings it to 23,306,645 bytes (measured locally with the release's craft-fonts ref, wasm-bindgen 0.2.129 and wasm-opt -Oz).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:44:13 -04:00
Guillermo Bueno 1cd9c0d875 M6: add bounded offline Penpot import and export 2026-10-25 14:36:23 +01:00
Brandon ThomasandClaude Opus 5.5 22c15e3206 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:14:22 -04:00
Brandon ThomasandClaude Opus 5.5 4b76c407d5 macOS signing: keep the temporary keychain until the build has signed with it
import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:14:05 -04:00
Brandon ThomasandClaude Opus 5.5 32b836fbc5 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:04:17 -04:00
NetterSilverandClaude Haiku 5.5 8e8314e273 Accept a unit typed after a space in number fields
Found while testing crates/ui-egui/src/field_math.rs:105 (Parser::factor, unit loop; end() at :109)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 11:35:08 +00:00
Jindřich ŠaršonandClaude Opus 5.5 23e3131e90 M6: Sketch import: .sketch files in recent files show their preview
A .sketch file opened from disk joins the recent files, where it had no
picture and a generic icon. FileKind::Sketch reads the preview.png Sketch
saves under previews/ (within the thumbnail caps) and gets the design-file
mark, like .fig. Test: sketch_files_show_their_preview_png.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:33:22 +01:00
Jindřich ŠaršonandClaude Opus 5.5 b8c120f30c M6: Sketch import: name .sketch in the file.open, file.import and MCP open_file docs
The command parameter docs (CLI `commands`, MCP list_commands, the Actions
palette) and the MCP open_file tool description listed .fig but not
.sketch; docs/mcp.md and docs/cli.md already did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:33:22 +01:00
Jindřich ŠaršonandClaude Opus 5.5 db1bc539b0 M6: Sketch import: offer FigCraft for .sketch on Windows and Linux too
Linux: the shared-mime-info package declares application/x-sketch (a zip,
*.sketch) and the desktop entry and AppStream metadata list it. Windows:
the installer registers a Figcraft.Sketch ProgId under .sketch's
OpenWithProgids and .sketch in figcraft.exe's SupportedTypes, never as
the default. Both hand the path to file.open, as macOS does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:25:07 +01:00
Jindřich ŠaršonandClaude Opus 5.5 c80801391f M6: Sketch import: offer FigCraft in Finder's Open With for .sketch
Info.plist declares Sketch's public type identifier (imported, so it is
known on a Mac without Sketch) and lists it as an Alternate, Viewer
document type: Sketch stays the default, FigCraft is offered. The app
already opened .sketch from an open-documents Apple event via file.open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:22:06 +01:00
Jindřich ŠaršonandClaude Opus 5.5 e712b359fc M6: Sketch import: merge main
Conflicts were only in the Change notes of docs/file-format-parity.md and
docs/target-app-parity.md: main's notes kept, the `.sketch` note added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:22:06 +01:00
NetterSilverandClaude Haiku 5.5 a107e62bcc Fix missing end arrowhead when a closed subpath follows an open one
Found while testing crates/pathops/src/points.rs:89-92 (Styled::arrowheads)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 12:13:55 +01:00
NetterSilverandClaude Haiku 5.5 142676191e Rename component parameters that are strict-mode reserved words
Found while testing crates/engine/src/component_codegen.rs:~226 (RESERVED list used by ident at :346)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 12:08:47 +01:00
skylartaylor fc344de33c M0: correct the migration qualification reference 2026-10-25 08:10:37 +01:00
NetterSilverandClaude Haiku 5.5 283c6de2b1 Keep significant digits in SVG matrix scale coefficients
Found while testing crates/svg/src/lib.rs:97 (num, used by matrix at :129 for the image pattern's patternTransform at :243-254)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 06:07:08 +00:00
skylartaylor a63aa1422f M-egui037: reconcile main and serialize screenshot commits with timeouts 2026-10-25 07:03:53 +01:00
aiongg 92682728e9 Follow-ups from the #1311 review: link notice clicks, variable scopes, corner tooltip (#1316)
* Prototype player: no clicks reach the prototype behind the link notice

* Selection colors: scopes filter the variable menu; the command binds as variable.bind does

* Style picker: color variables scoped to every selected layer's fill or stroke

* Corner button: named Corner smoothing on polygons and stars

* docs/gaps.md: known gaps from the #1311 review

* cargo fmt

* Style picker: clippy
2026-10-25 01:09:29 -04:00
skylartaylor 3d9c8279ec E21: reconcile current text, export and Dev Mode changes 2026-10-25 05:49:49 +01:00
skylartaylor d7c7986347 E21: separate capture, IME and field-save regression phases 2026-10-25 05:32:03 +01:00
skylartaylor 2ef3ebb45d E21: require release font coverage and stabilize capture deadline tests 2026-10-25 05:17:15 +01:00
NetterSilverandClaude Haiku 5.5 a6a8d7149c Scale the path of text on a path in transform.scale
Found while testing crates/engine/src/cmd/arrange.rs:~495 (scale_node, the Text branch). Compare crates/layout/src/lib.rs:136, which calls TextPath::scale on resize.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 03:52:14 +00:00
aiongg 2a3b7d4fa7 Merge pull request #1311 from aiongg/dotcoocoo-batch
Merge 13 open PRs (tested batch)
2026-10-24 23:34:17 -04:00
skylartaylor 836f1082bd egui037: qualify current figcraft integration 2026-10-25 03:54:01 +01:00
aiongg e44ef587e5 docs/mcp.md: a slice in a frame or group with contentsOnly (#1305) 2026-10-25 02:40:12 +00:00
aiongg d135620b9f Dev Mode: the Assets panel's Publish refuses too (#1277) 2026-10-25 02:40:12 +00:00
aiongg 32199cc8e8 Text on a path tool: carry fill and effect bindings as text.onPath does (#1298, #1301) 2026-10-25 02:40:12 +00:00
aiongg b5ed2913d7 Merge #1307 2026-10-25 02:21:44 +00:00
aiongg 86bcfaddb9 Merge #1305
# Conflicts:
#	docs/file-format-parity.md
2026-10-25 02:21:44 +00:00
aiongg b95a04dc43 Merge #1304
# Conflicts:
#	docs/prototyping-parity.md
2026-10-25 02:21:29 +00:00
aiongg ad40507450 Merge #1277 2026-10-25 02:21:19 +00:00
aiongg 7aab188389 Merge #1301 2026-10-25 02:21:19 +00:00
aiongg d31a43419d Merge #1298
# Conflicts:
#	docs/ui-parity.md
2026-10-25 02:21:19 +00:00
aiongg 855631759f Merge #1288 2026-10-25 02:21:08 +00:00
aiongg c104757f59 Merge #1286 2026-10-25 02:21:08 +00:00
aiongg cdf37f9fc9 Merge #1284 2026-10-25 02:21:08 +00:00
aiongg 90bbd6a6d4 Merge #1281 2026-10-25 02:21:07 +00:00
aiongg e9edc298aa Merge #1283 2026-10-25 02:21:07 +00:00
aiongg cff215a107 Merge #1273 2026-10-25 02:21:07 +00:00
aiongg 996d96580e Merge #1293 2026-10-25 02:21:07 +00:00
dotCooCoo 6e86e26d14 M9: the hello deadline refuses a connection even when a read times out early
On Windows a socket's read timeout counts whole milliseconds, so a read
bounded by the hello deadline can time out a moment before it.
`next_message` then took the timeout for a closed connection: the client
got no "no hello in time" refusal, and the socket closed only after the
2 s linger. `Duplex::read` now reads again until the deadline has passed.

Tests:
- `connections_without_a_hello_are_told_why_they_end` (new): twelve
  connections that never say hello each get the refusal. Failed 5 of 10
  runs without the fix.
- `pre_hello_lifetime` notes the end of the stream on a thread of its own
  instead of reading one message per 300 ms poke, where pongs queued
  ahead of the close delayed it.
- `quiet_guests_stay_and_silent_connections_are_dropped`: the guest takes
  only the host's keepalive. Its 600 ms idle timeout also bounded the wait
  for the welcome.
- `two_files_are_hosted_and_joined_independently`: the two joins race, so
  the file whose welcome arrived last is in front. The test checks that,
  then brings B to the front.
- `a_local_share_is_joined_by_its_code_and_edits_sync`: once the share
  stops, another program's host can take the freed port and refuse the
  code.
2026-10-24 19:00:42 -07:00
Dylan M. TaylorandClaude Sonnet 5.5 9f78875338 security: linear vector import, stroking and rounding, validation, and cyclic dev snapshots (#1306)
* security: linear vector import, stroking and rounding, validation and a cycle check for dev snapshots

Closing a subpath, rounding corners and chaining stroke runs each rescanned the whole
network per step, and Document::validate searched a parent's children per child. They now
update only the current subpath's last segment, rebuild each region loop in one pass, walk
per-vertex adjacency lists, and check edges against a once-built index. A dev snapshot whose
layers contain a cycle under the root is refused instead of reconstructed.

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

* fix(doc): address review findings for snapshot children and 2-segment rounded loops

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-24 21:17:36 -04:00
skylartaylor 99d66401d6 EGUI037: port FigCraft lifecycle, capture and text input to egui 0.37
Preserve current main and pin the reviewed fork dependency family.
Qualify bundled and system-font configurations, plugins and regression tests.
2026-10-25 02:31:42 +02:00
dotCooCoo 4f0649ad52 M7: the export settings name Ignore overlapping layers and offer Include bounding box for text layers only
The export setting's advanced options name the contentsOnly option
"Ignore overlapping layers" and offer Include bounding box only when
every selected layer is a text layer. A setting that already has Include
bounding box on still shows the option, so it can be turned off, and the
export still applies it. The saved keys and the contentsOnly and
includeBoundingBox params of export.add, export.update and file.export
are unchanged.

Every catalog's "Contents only" row becomes an "Ignore overlapping
layers" row.
2026-10-24 17:21:42 -07:00
dotCooCoo 9c20d06abd M7: with Contents only on, a slice in a frame or group exports only that container
A slice exported every layer of its page that showed in its area, whatever
its Contents only setting said. With Contents only on (the default), a slice
inside a frame or group now exports only that frame or group and the layers
in it. With it off, or for a slice on the page or in a section, the slice
exports everything in its area as before. The Export section's preview of a
slice follows its first export setting in the same way.
2026-10-24 15:53:33 -07:00
dotCooCoo ae57bf84d0 M5: a notice before a prototype's Open link leaves for a website
The player opened a prototype's Open link at once, also from an After
delay trigger with no click. The help center's Prototype actions page
says that the viewer is told they are leaving before an external
website opens, with a checkbox that skips this page on the current
device.

An Open link to an http or https address now shows a notice with the
link, Open link, Cancel and "Don't show this again". Open link opens
it; Cancel, the close button or Escape drops it. While the notice
shows, the prototype takes no keys, and the presenter's C, Shift+C and
F keys do nothing. When Open link is pressed with the box ticked, the
desktop app saves the choice with its preferences; the web build keeps
it until the page reloads. proto.linkNotice {show} turns the notice on
or off. A mail link opens at once, as before, also while the notice
shows. A second link to a website that fires while the notice shows is
dropped.
2026-10-24 14:10:41 -07:00
dotCooCoo 0978b44385 M4: text on a path takes a fill binding made through fills
The bindings that move with the path's fills and effects are now found with list_binding, which reads `fills` as the first fill's color, as style_sets in cmd/styles.rs does.
2026-10-24 14:02:50 -07:00
dotCooCoo 2c38768872 M6: a layer drag or eye and padlock sweep that ends in Dev Mode changes nothing
A drag or sweep started in Design and released after switching to Dev Mode leaves the layers as they were and shows the Dev Mode note.
2026-10-24 14:01:22 -07:00
Marco Arnulfo f4d3f796d6 Dialogs start in the folder of the last file picked (fixes #1290) 2026-10-24 23:00:08 +02:00
dotCooCoo e46825b1cb M4: Text on a path checks masks only for layers that give a candidate
The hover search now recurses into each child first and looks up its mask only when the child added a candidate, as layers_at in crates/doc/src/geometry.rs does.
2026-10-24 13:51:38 -07:00
dotCooCoo 949d6c2830 M4: text on a path keeps its effects when the path's effect style has none
A path linked to an effect style with no effects gave the text an empty effect list and the style link. The text now takes the path's effects only when there are any.
2026-10-24 13:51:32 -07:00
dotCooCoo 98d061df10 M4: text put on a path takes the path's fill and effects
text.onPath, and the Design panel's Put text on path button that runs
it, kept the text layer's own fill and effects when the text went onto
a path. The help center's Guide to text says that the vector object's
fill and effects are transferred to the text layer.

The text now takes the path layer's fills, with their fill style and
variable bindings, when one of them shows, and the path layer's effects,
with their effect style and bindings, when it has any. The text's own
bindings on those fields are dropped. When the path has no visible fill
or no effects, the text keeps its own.
2026-10-24 12:37:59 -07:00
Mojtaba Jafarzadeh 241b349d7d Merge branch 'main' of https://github.com/storytold/figcraft into fix/share-key-and-large-file-publish
# Conflicts:
#	contributors/people.toml
2026-10-24 22:50:16 +03:30
Mojtaba JafarzadehandClaude 726700aa2a contributors: add Mojtaba Jafarzadeh's real name
Co-Authored-By: Claude <[email protected]>
2026-10-24 22:35:07 +03:30
Mojtaba JafarzadehandClaude 9a006175c8 ui: server key field in the Share popover
A server with a key rejects publishes without one, but Preferences' server
address never holds a key. Add a "Server key" field to the Share popover's
Advanced section and pass it to collab.share so a keyed server can be used
from the popover without the Publish dialog.

Co-Authored-By: Claude <[email protected]>
2026-10-24 22:35:07 +03:30
Mojtaba JafarzadehandClaude 2a08ad3244 collab-net: wait longer for one-shot server replies
Publishing uploads a whole file, and a large project on a slow link takes
the server several minutes to receive and answer. The one-shot request
connections (hosting, the publish upload, a library, a plugin directory)
waited the session idle timeout (45s), so a large publish timed out with
"no answer from the server" before the server finished. Add a dedicated
request_reply_timeout (5m) for these connections, keeping the session idle
timeout unchanged.

Co-Authored-By: Claude <[email protected]>
2026-10-24 22:35:05 +03:30
NetterSilverandClaude Haiku 5.5 42fbe8f00c Skip zero-length segments when finding arrowhead direction
Found while testing crates/pathops/src/lib.rs:352 (path_end, used by arrowhead)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 18:52:03 +00:00
dotCooCoo d8900e1c16 M4: the Text on a path tool
Text went onto a path only with the Design panel's Put text on path
button (a text layer and a path selected together) or with text.onPath.
The Text on a path tool, in the Text flyout of the toolbar
(tool.textOnPath), shows the I-beam over a shape's or vector's path, and
a click there puts new, empty text on the path, starting at the click,
and starts typing. The text takes the path layer's place, transform,
box, constraints and effects, and its fills when one of them shows; the
path layer is removed. Ending the edit with nothing typed puts the path
back with no undo step.

The tool measures the distance on the page, so a layer scaled more one
way than the other, or shown smaller, is reached where it shows. Shapes
in front whose outline is away from the click do not hide a path behind
them, and paths that a mask or a clipping frame hides at the click are
skipped, as are locked layers and layers inside instances.

A double-click into a text layer that was just made no longer makes it
count as an existing layer, so leaving it empty records no undo step,
with this tool and with the Text tool.
2026-10-24 11:37:47 -07:00
NetterSilverandClaude Haiku 5.5 d9a1de91d3 Fix code export printing whole numbers of 2^63 or more as i64::MAX
Found while testing crates/engine/src/codegen.rs:112 (num)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 17:39:10 +00:00
dotCooCoo 27fc3cec81 Docs: list each file-format status topic once
The status line of docs/file-format-parity.md repeated eight topics,
from SVG/PDF export depth refusal (#208) to "last major". Each topic is
now listed once, and the boards entry closes its parenthesis.
2026-10-24 10:34:29 -07:00
Jindřich ŠaršonandClaude Opus 5.5 1d6382f2ad M6: Sketch import: list .sketch in figcraft-cli --help
The Files paragraph of the CLI usage text now names .sketch (Sketch 48 or
newer, best effort), as docs/cli.md already does (PR review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 19:30:18 +02:00
NetterSilverandClaude Haiku 5.5 544a29c93b Keep style runs disjoint when inserting text at a run boundary
Found while testing crates/doc/src/text.rs:592 (TextData::replace_range, with normalize at :573)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 17:17:22 +00:00
Jindřich ŠaršonandClaude Opus 5.5 87db9522b4 M6: Sketch import: merge main (Boards M12 and community fixes)
Conflicts resolved keeping both sides: `.sketch` stays in `is_document`
(now via `OsStr::to_str`); ROADMAP and the parity, roadmap and
file-format docs keep main's headers and rows with the `.sketch` notes
and history rows added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 19:13:03 +02:00
AganzeFelicite 86aad2bb6a M7: offer the installed styles of an embedded font family (#1246)
Release builds embed some families with only a few styles (Poppins
Regular, Italic and Bold), and the first font scan dropped every
installed face of such a family, so an installed Poppins SemiBold or
Medium was never offered. The first scan now keeps the embedded faces,
which still win for the styles they cover, and offers the installed
styles they lack beside them, as a rescan already did.

Closes #1246
2026-10-24 18:39:44 +02:00
AganzeFelicite c743f8ec1e Merge remote-tracking branch 'upstream/main' into fix/adobe-fonts-macos-298
# Conflicts:
#	ROADMAP.md
#	crates/text/src/fontdb.rs
#	docs/roadmap.md
2026-10-24 18:20:00 +02:00
dotCooCoo 954f9036da Docs: parity rows for mouse-trigger delays, outline options and missing fonts
Three parity rows still listed work that has landed on main. The
prototyping Triggers row listed delays on mouse enter, leave, down and
up as missing; they landed on 2026-10-13. Canvas & navigation's
remaining work in target-app-parity listed outline mode's Include
hidden layers and Include object bounds, which View > Outlines has.
The file-format Fonts row said missing fonts give warnings only; the
desktop app lists them in the Missing fonts window, and replacing them
is still missing.

Each file gets its status line and a revision-history row. The
percentages and hours are unchanged.
2026-10-24 09:19:45 -07:00
watchakorn-18k d1d3b3a86c M8: group the copy commands under Copy as in the layer context menu
Copy as CSS, SVG, PNG and Copy link to selection move into a Copy as
submenu, placed under Paste to replace, instead of four flat rows near
the bottom of the menu.
2026-10-24 23:12:11 +07:00
watchakorn-18k 09b28f9b2d M8: Copy link to selection in the layer context menu
Edit > Copy as > Copy link to selection (edit.copyLink) was only in the main
menu. Add it to the right-click menu on a layer, after Copy as PNG, labelled
with the command's own translated label.

The command now says "Link copied" when it copies, and copies nothing when
no layer is selected (it used to put figcraft://node/0 on the clipboard).
2026-10-24 22:34:35 +07:00
dotCooCoo ac399dbe73 M6: Dev Mode says why when a page or layer drag is refused
Page rows and layer rows take drags in Dev Mode again. A drag on either leaves the order as it was and shows the Dev Mode note, as a double click or the row menu already does.
2026-10-24 08:34:13 -07:00
watchakorn-18kandClaude Sonnet 5.5 42c7f89543 M10: keep Hangul first in a Korean UI when Thai is also needed
samples() reversed the whole script list for a Korean UI, which put the
Bengali and Thai entries ahead of Hangul. Swap Hangul and Kana instead,
so the order is Hangul, Han, Kana, then Bengali and Thai. A test covers
the mixed case.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cs5R9irCMt9rwfhpkwxvSn
2026-10-24 22:09:09 +07:00
dotCooCoo 41fe7d95d8 M1: the iOS button next to Corner smoothing sets 60%
The Corner smoothing row showed "iOS: 60%" as plain text. The help
page on corner radius and smoothing says to click iOS to set corner
smoothing to 60%. The row now has an iOS button that sets
cornerSmoothing to 0.6 on the selection in one undo step, with the
hover text "Set corner smoothing to 60%".
2026-10-24 08:01:53 -07:00
dotCooCoo 6fa00af2be Docs: components parity status line for the text style variables 2026-10-24 07:32:51 -07:00
watchakorn-18kandClaude Sonnet 5.5 f0764be987 M10: Thai UI fonts and a Thai draft catalog
Layer, page and frame names in Thai drew as tofu squares in the panels and
on the canvas labels: the UI loaded system faces for CJK and Bengali only.

- cjk.rs: Thai is a fifth script (U+0E00..U+0E7F). When a name, a typed text
  or the UI language needs it, the desktop app adds an installed Thai face
  (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya,
  Sarabun, Loma, Garuda, Waree). English users still load nothing; the web
  build leaves Thai out, like Bengali.
- locales/th.toml: machine-drafted Thai catalog (every UI string, 589
  command labels, the menu wordings), marked as a draft needing native
  review. i18n.rs: Thai has no plural forms.
- docs/translating.md and docs/localization-parity.md updated.

cargo test -p figcraft-ui-egui passes (662); cargo xtask i18n-report,
assets and layers are ok. cargo xtask wasm and clippy were not run
(no wasm32 target here; clippy -D warnings already fails on other files
with Rust 1.95).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cs5R9irCMt9rwfhpkwxvSn
2026-10-24 21:25:22 +07:00
dotCooCoo 811d599960 M4: corner smoothing on polygons and stars
Polygon and star outlines did not use their corner smoothing: the value
was stored, imported and exported, but `shape_path` drew their corners
as plain circular arcs, and the Design panel showed the Corner
smoothing field only for rectangles and frames. Rounded polygon and
star corners now take smoothing the way rectangle corners do. Each
corner keeps a circular arc over the unsmoothed part of its turn and
eases into both edges, and it starts (1 + smoothing) times the plain
corner's length from the vertex. When a smoothed corner does not fit
in half of its shorter edge, smoothing is reduced first, then the
radius, as for rectangles. The Individual corners button opens the
panel for polygons and stars too, with the Corner smoothing row and no
field per corner.
2026-10-24 07:22:36 -07:00
dotCooCoo 268c0ead11 M3: apply variables to a text layer's font family and font style
The Design panel's font family and font style menus offered no way to
bind a string variable; these bindings could be made only by commands
such as variable.bind. Each menu now starts with Apply variable, which
opens the variable picker on string variables for the selected text
layers. A bound family or style shows its variable's chip in place of
the menu. A click on the chip picks another variable, and its detach
button unbinds it. While a text is being edited (a caret or selected
characters), the menus have no Apply variable item and show the plain
dropdown. A pick applies to the selected characters, or to the whole
layer when no characters are selected, as before.
2026-10-24 06:08:17 -07:00
dotCooCoo 9f280cb2e0 M3: style variable pickers offer only variables scoped to the field
The variable picker opened from a style's field listed every variable of
the field's type, and the Fill and Stroke style picker's Color variables
listed every color variable, whatever their scopes. Both now offer only
variables scoped to the property, as the Design panel's pickers do. A
paint style's colors take variables scoped to a fill or to strokes, an
effect style's fields take effect variables, and a text style's fields
take the variables of their font property. Layout grid fields still take
any number variable. style.bind still binds a variable outside its
scopes, as variable.bind does.
2026-10-24 05:24:51 -07:00
NetterSilverandClaude Haiku 5.5 598a4a2867 Measure distribute span to the group's outermost edge
Found while testing crates/engine/src/cmd/arrange.rs:210 (distribute, also used by arrange.packH/packV)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 11:51:08 +00:00
dotCooCoo 337f9d72e0 M3: bind variables to a text style's fields from the style editor
The style editor bound variables to paint colors, shadow colors, blur
radii and grid sizes, but a text style's fields took variables only
through style.bind. The font and font style menus now start with Apply
variable, which opens the picker on string variables, and the size, line
height and letter spacing fields show a variable button on hover for
number variables, as the Design panel's number fields do. A bound field
shows its variable's chip in place of the value, as in the Design
panel. A click on the chip picks another variable, and its detach
button unbinds it.
2026-10-24 04:11:03 -07:00
Ridzzz0Alam 2baeff3452 M1: dash limit counts the pattern restarting at each subpath (#1060)
kurbo restarts the dash pattern at every MoveTo, so many short subpaths
with a pattern of tiny dashes before a long gap made ~1M dashes (5M path
elements) while the length-based estimate allowed them. stroke_center
now adds one pattern's length per subpath to the estimate: an upper
bound on the dashes kurbo makes.

Test: each_subpath_restarting_the_dash_pattern_counts_toward_the_limit
(5M elements before).

Reported by CodeRabbit on #1278.
2026-10-24 12:44:55 +02:00
Ridzzz0Alam 494387a6a8 M1: a dash pattern too fine for its path strokes solid (#1060)
stroke_center handed any dash pattern to kurbo::stroke, which makes one
outline per dash: a 0.01/0.01 pattern on a 10,000-unit line made 2.5M
path elements, and a long line with a tiny pattern ran out of memory.

The variable width stroke already drew solid past MAX_DASHES (20,000);
that estimate is now width::dashes_fit and stroke_center uses it too, so
both strokes follow one rule. The path length is only measured for
dashed strokes.

Test: a_dash_pattern_too_fine_for_its_path_strokes_solid (2.5M elements
on the old code).
2026-10-24 12:13:40 +02:00
dotCooCoo 29ab17c222 M6: Dev Mode refuses edits from dropped files, the Pages list and the Assets panel
In Dev Mode the menus, shortcuts, clipboard, canvas and layers panel
refuse design edits, but an image or SVG dropped on the canvas was
still imported. The Pages list still added, renamed, reordered,
duplicated and deleted pages. The Assets panel still placed instances,
swapped them on an Alt-drop, applied styles, changed Hide when
publishing, accepted library updates and added, removed or swapped
libraries.

All of these now go through dev_mode_refuses with the command they
would run, and show its note. A dropped document still opens as a file
of its own, and the drop overlay shows the note instead of "Drop to
import". A click in the Pages list still opens a page, and page rows
take no drags in Dev Mode.
2026-10-24 02:57:11 -07:00
Ridzzz0Alam 1cb5f403fc M1: a section's title draws above the labels of frames inside it (#1150)
boards() listed a section before its contents, so the label of a frame
near the section's top-left was painted over the section's title pill,
and label_at (last label wins) gave clicks on the pill to that frame.
A section now comes after everything inside it: its pill draws on top
and takes the click.

Test: a_sections_pill_is_above_the_labels_of_frames_inside_it (fails on
the old order).
2026-10-24 11:52:57 +02:00
skylartaylor 9825a4926a M2.1: migrate FigCraft to egui 0.37 and reconcile current main 2026-10-24 11:43:04 +02:00
NetterSilverandClaude Opus 5.5 10702311f3 Require a per-client secret to resume a collab client id (#1193)
Fixes #1054

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:26:27 -04:00
Ridzzz0Alam 4da6a11bad M1: render bounds stop at the depth the renderer draws (#1062)
world_render_bounds and children_render_bounds recursed once per level
with no limit, so a crafted chain of ~20,000 nested groups overflowed the
stack and aborted the app on PNG/JPG/WebP export, rasterize, copy as PNG
and thumbnails. A stack overflow is an abort, not a panic, so the guard
can't catch it.

The walk now carries a depth and stops descending at 256, the depth
draw_layer draws to (MAX_EXPORT_DEPTH): bounds match what is drawn and
shallower trees are unchanged.

Test: render_bounds_stop_where_drawing_stops_on_very_deep_chains (aborts
on the old code).
2026-10-24 11:26:27 +02:00
NetterSilverandClaude Opus 5.5 a80df8ed18 Count scalar kiwi fields against the decoded-value budget (#1188)
Fixes #1057

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:26:06 -04:00
NetterSilverandClaude Opus 5.5 35f5faa2e6 Check SVG nesting depth after DTD entity expansion (#1177)
Fixes #1081

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:25:43 -04:00
NetterSilverandClaude Haiku 5.5 8ab0de2690 Keep wrong join-code streak when a correct code is tried (#1174)
Fixes #1088

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:25:17 -04:00
NetterSilverandClaude Haiku 5.5 7bc594632d Cap layout.setTrack track count at MAX_TRACKS (#1168)
Fixes #1098

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:24:53 -04:00
NetterSilverandClaude Opus 5.5 9c0d1c19fe Restore the clipboard when a command fails and is rolled back (#1167)
Fixes #1100

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:24:29 -04:00
NetterSilverandClaude Haiku 5.5 8864e42dce Clear plugin settings when a plugin id is re-registered (#1159)
Fixes #1116

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:24:05 -04:00
NetterSilverandClaude Haiku 5.5 98298a3519 Validate image member names when reading .figcraft archives (#1148)
Fixes #1122

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:23:43 -04:00
NetterSilverandClaude Opus 5.5 7df328d1ca Clip Inside/Outside strokes by the shape's even-odd fill rule (#1253)
Found while testing crates/pathops/src/lib.rs:326-333 (stroke_outline_of, StrokeAlign::Inside and Outside arms calling binary(), which uses FillRule::NonZero at :82)

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:23:23 -04:00
NetterSilverandClaude Haiku 5.5 3a1ddf339c Convert text decoration colour in colour profile conversion (#1247)
Found while testing crates/doc/src/colors.rs:30 (TextStyle::for_each_color_mut, used by TextData, Style and Document::map_colors / set_color_profile)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:22:48 -04:00
43a255fe16 Clamp control-channel document.inspect depth to 64 (#1162)
Fixes #1115

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-24 05:17:25 -04:00
NetterSilverandClaude Haiku 5.5 2d1fabda1f Reject non-finite percent strings for lineHeight and letterSpacing
Found while testing crates/engine/src/cmd/props.rs:145 (line_height) and :153 (letter_spacing)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 11:10:23 +02:00
vetlucasmartins cd606ef8cd Merge remote-tracking branch 'upstream/main' into feat/tailwind-output 2026-10-24 10:05:51 +01:00
Dylan M. Taylor cb38f68c0c fix(security): linear docprops reordering and deduplicate restored deletion nodes (#1258)
* fix(security): linear docprops reordering and deduplicate restored deletion nodes

* style: format if-let condition
2026-10-24 04:45:01 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 3739b2863a fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity (#1264)
* chore: explicit test imports in engine, part 1 (Sonar S2208)

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

* fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-24 04:41:31 -04:00
Dylan M. Taylor 0cca56984f fix(security): bound decoration steps, text-on-path elements, and rename expansions (#1257) 2026-10-24 04:39:12 -04:00
Dylan M. Taylor 25d48530a9 fix(security): owner-only recovery permissions, linear cli export suffixing, and web noopener (#1256) 2026-10-24 04:37:38 -04:00
Dylan M. Taylor 996bf871cd fix(security): prevent symlink traversal in asset walker, safe cert tmpdir, and atomic icon copy (#1255) 2026-10-24 04:36:46 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 fdc4f74034 chore: drop redundant 'static lifetimes in consts (Sonar S8863) (#1254)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-24 04:28:06 -04:00
dotCooCoo b2cb613ef0 Contributors: add dotCooCoo (Robert Lee) 2026-10-24 01:10:25 -07:00
dotCooCoo ce8e9a7d60 M3: apply and detach color variables from Selection colors
A Selection colors row could take a color style but not a color
variable, and a row linked to a style or variable could not be
unlinked from there.

selection.bindColorVariable binds a color variable to every solid fill
and stroke slot a row stands for. It replaces a color style on that
list and any earlier binding of the slot. A text range with fills of
its own takes the variable's value in the layer's modes. Gradients and
slots outside the variable's scopes are refused.
selection.detachColor unlinks the style or variable of a row's slots
and keeps their colors.

In the Design panel, the Styles menu of an unlinked row lists the
document's color variables after its color styles, and a linked row
shows a Detach button in the Styles button's place.
2026-10-24 01:10:25 -07:00
bflatastic dbf57d30c2 Merge main into #1003 2026-10-24 14:21:15 +07:00
JimKnoxxandClaude Opus 5.5 76804506dc M5: an open overlay follows the layer that opened it when the view changes
Review follow-up. The overlay kept the screen rect its trigger had when it
opened, so after a resize, a size option change or a pan a trigger-relative
(or manual) overlay stayed where the trigger used to be. It now keeps that
rect in page units of the presented frame and places it again on every
layout. The dropdown test switches to Fill while the menu is open. The
anchor's is_screen gets a doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 09:19:33 +02:00
JimKnoxxandClaude Opus 5.5 545cd630fd M5: overlay positions relative to the layer that opens them
From the Figma forum request "Set overlay position relative to element,
not to page": an open overlay action takes anchor: screen (default) |
triggerInside | triggerOutside. With a trigger anchor the position preset
is measured on the layer that opens the overlay instead of the screen.
Outside puts top presets above it and bottom presets below it with the
edges lined up (a dropdown is Bottom left + Outside); Center centres it
on that layer. Manual is unchanged.

The interaction editor gets Relative to (Whole screen / Trigger layer)
and Inside / Outside. The player, the command (and so MCP and the
control channel) carry it; the command's params doc lists it. Figma has
no such setting, so .fig export writes it as a manual offset. Old files
and JSON without anchor read as screen and write unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 09:19:33 +02:00
BFlat 70227e30fc Merge pull request #1009 from lucasmartins-ai/feat/data-fill
data.fill and text.fit: fill copies of a frame from rows of data
2026-10-24 14:17:14 +07:00
BFlat 127247433a Merge pull request #1004 from kkilchrist/feat-1001-vector-path
node.set: accept `path` to replace a vector's geometry
2026-10-24 14:17:06 +07:00
BFlat 448019c43a Merge pull request #1005 from kkilchrist/fix-992-reparent-index
node.reparent: index is the layer's final position
2026-10-24 14:16:56 +07:00
BFlat 6d91528d2a Merge pull request #915 from Hamza0101/fix/mcp-inspect-bound
MCP: inspect_document answers within what a client can read
2026-10-24 14:16:48 +07:00
BFlat 62e1e57626 Merge pull request #1006 from kkilchrist/fix-993-boolean-default-main
Show a boolean property's default on the main component's bound layers
2026-10-24 14:16:37 +07:00
BFlat a111638378 Merge pull request #1008 from lucasmartins-ai/feat/css-export-px
variable.export css: number lengths in px
2026-10-24 14:16:29 +07:00
bflatastic 0b3ce29eb3 Canvas: hover distances go when the pointer leaves the canvas 2026-10-24 14:14:37 +07:00
bflatastic 9f766dcbf7 Merge main into #1009 2026-10-24 13:47:22 +07:00
Muamer Mustafic 340d24955a M7: re-run CI (#988) 2026-10-24 08:37:51 +02:00
Muamer MustaficandCursor 7f18940c1d M7: toolbar flyout carets get a wider target clear of their tool (#988)
Co-authored-by: Cursor <[email protected]>
2026-10-24 08:37:51 +02:00
BFlat e3e39bbc4d M6: Dev Mode measurements kept on the canvas, a read-only layers panel (#1248)
* M6: Dev Mode measurements kept on the canvas, a read-only layers panel

Measurements placed between two layers, along a side or to the layer
around one (devMode.addMeasurement / removeMeasurement / measurements),
stored on the layer they start from (saved, synced, undoable; old files
open without them), drawn in Dev Mode as labelled redlines that follow
the layers and listed, placed and removed in the Inspect tab. In Dev Mode
the layers panel only selects and expands.

* M6: Dev Mode measurements: explicit imports, a measurement row of its own (Sonar)
2026-10-24 13:33:26 +07:00
bflatastic 80bf75526b #1026: the localization page's revision history lists the Vietnamese catalog 2026-10-24 13:04:28 +07:00
bflatastic 0168d25d85 #1026: the intensity-width test skips labels egui's own font can't draw 2026-10-24 13:04:14 +07:00
bflatastic 030dffc2eb #1026: Vietnamese drops two strings the close dialog no longer uses 2026-10-24 13:02:42 +07:00
bflatastic a5afa004b4 #1224: Italian drops two strings the close dialog no longer uses 2026-10-24 13:02:41 +07:00
bflatastic a6949ec2e9 #1010: a zip export stops at 256 MB of files, which are all in memory at once 2026-10-24 13:02:34 +07:00
bflatastic b6489aba3b #989: .fig pages keep room for their top-level layers past large library copies 2026-10-24 13:02:25 +07:00
bflatastic 9b78ff8c4a #989: explicit imports, the .fig test helper named main_component 2026-10-24 13:02:24 +07:00
bflatastic d9d9e1d978 #1009: Dev Mode refuses data.fill, a design edit recorded as one grouped step 2026-10-24 13:02:11 +07:00
bflatastic a7b7dfe1c4 #1009: data.fill refused before it copies or writes in a read-only live file 2026-10-24 13:01:55 +07:00
bflatastic 35fada1161 #1009: data.fill and text.fit in smaller helpers (Sonar) 2026-10-24 13:01:48 +07:00
bflatastic 5376e4d25d #1009: explicit imports 2026-10-24 13:01:47 +07:00
bflatastic bc0f61eeee #1004: a size given with a new path still fixes its auto layout axis 2026-10-24 13:01:46 +07:00
bflatastic b00d12b623 #915: get_node refuses a layer too large for maxBytes on its own; a window only an offset made says it is paging 2026-10-24 13:01:33 +07:00
bflatastic b6f5c29d19 #915: MCP get_node pages past a child too large for the limit and keeps the truncation marker 2026-10-24 12:58:10 +07:00
bflatastic 91af3782e0 #915: the inspect response-limit tests join the MCP crate's one test binary (tests/it, as on main since #1208) 2026-10-24 12:57:58 +07:00
bflatastic 647e5f17d9 #1011: single-call Tailwind variables, rem only where it converts 2026-10-24 12:57:52 +07:00
bflatastic 9361b230b5 #1011: explicit imports, a method reference 2026-10-24 12:57:43 +07:00
bflatastic e1117d2a3c #1008: token_roles names its imports 2026-10-24 12:57:42 +07:00
bflatastic 6fd4194240 Merge main into #681
# Conflicts:
#	contributors/people.toml
#	crates/ui-egui/locales/fr.toml
2026-10-24 12:54:57 +07:00
bflatastic 565e0e8266 Merge main into #1217 2026-10-24 12:54:49 +07:00
bflatastic 7a7d71f904 Merge main into #1216 2026-10-24 12:54:42 +07:00
bflatastic 3e99f489e1 Merge main into #1026
# Conflicts:
#	docs/localization-parity.md
#	docs/translating.md
2026-10-24 12:54:30 +07:00
bflatastic 0c549a4147 Merge main into #1224 2026-10-24 12:54:27 +07:00
bflatastic c31f886c05 Merge main into #1215 2026-10-24 12:54:15 +07:00
bflatastic 82568faf3b Merge main into #1010 2026-10-24 12:54:07 +07:00
bflatastic 48a5e03348 Merge main into #954
# Conflicts:
#	ROADMAP.md
2026-10-24 12:54:04 +07:00
bflatastic a3509895d4 Merge main into #989 2026-10-24 12:54:02 +07:00
bflatastic 61855d362b Merge main into #951 2026-10-24 12:54:01 +07:00
bflatastic a0bd65f7b4 Merge main into #1009 2026-10-24 12:53:58 +07:00
bflatastic 13eba6a3ea Merge main into #1003
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-24 12:53:57 +07:00
bflatastic ede3c634ad Merge main into #1004 2026-10-24 12:53:55 +07:00
bflatastic 6a172e4e52 Merge main into #1005 2026-10-24 12:53:53 +07:00
bflatastic 1b41d129e6 Merge main into #915 2026-10-24 12:53:52 +07:00
bflatastic 7cd4c5216d Merge main into #1006 2026-10-24 12:53:50 +07:00
bflatastic be8991d6a8 Merge main into #1011 2026-10-24 12:53:49 +07:00
bflatastic ce32e07fe0 Merge main into #1008 2026-10-24 12:53:48 +07:00
Salah1StandClaude Opus 5.5 84ca850d5f M4: justified text doesn't widen U+200B
Thai, Lao, Khmer and Burmese are written without spaces between words.
Tools that know the words insert U+200B ZERO WIDTH SPACE so lines can
break between them, and line breaking already treats it as a space. But
justification counted every space-class char as a gap, so a justified
paragraph got extra room at every U+200B: visible gaps between all its
words, which is not how these scripts are justified.

Item::stretches() (a space other than U+200B) is used for the justify
gap count, the trailing width and the pen advance. Line breaking is
unchanged. Test: on a justified line of words joined by U+200B and one
real space, the extra room goes to the real space only; with no real
space the line is laid out as if left-aligned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:15:57 +07:00
SusieeTheFemboyandGPT-6 Luna 8989e7b253 Text: Backspace removes Thai and other marks one at a time (#1220)
Co-Authored-By: GPT-6 Luna <[email protected]>
2026-10-24 06:27:49 +02:00
BFlat 3476f4b1e7 Merge pull request #1244 from storytold/batch/r2-1
Community batch r2-1: 63 reviewed PRs
2026-10-24 11:15:53 +07:00
NetterSilverandClaude Sonnet 5.5 8b94da604a fix: use apostrophe-aware title case on the canvas text path
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 05:52:05 +02:00
bflatastic 9f60f5470d Batch r2-1 review: status lines and rows in ROADMAP, prototyping and UI parity 2026-10-24 10:31:31 +07:00
bflatastic 8af2efd1b9 Batch r2-1 review: U+2007 glues beside ideographs too 2026-10-24 10:31:29 +07:00
bflatastic d0db758684 Batch r2-1 review: SVG ids unique as written; a filled open curve closes for its fill 2026-10-24 10:31:26 +07:00
bflatastic 31d4c6eba4 Batch r2-1 review: per-side strokes follow smoothed corners for every alignment 2026-10-24 10:31:24 +07:00
bflatastic c45c693278 Batch r2-1 review: a report reason blank after cleaning is refused 2026-10-24 10:31:22 +07:00
bflatastic d114c6ed92 Batch r2-1 review: CLI export file names stay within 200 bytes 2026-10-24 10:31:19 +07:00
bflatastic 46b02d44e6 Batch r2-1 review: paste_nodes refuses two nodes mapped to one new id 2026-10-24 10:31:17 +07:00
bflatastic 5d7eb04d44 Batch r2-1 review: the hello and close-drain deadlines bound every read
Duplex::read_until gives a reader a deadline: each socket read waits at most what's left of it.
The server reads the handshake and hello under the hello deadline, so a hello trickled in
fragments can't outlast it; manage's close drains under CLOSE_DRAIN, so a silent server can't
hold it for the idle timeout.
2026-10-24 10:31:15 +07:00
bflatastic aac76ebeca Batch r2-1 review: Save on close waits a frame for a panel field to commit
A click on Save takes the keyboard from a focused panel field, which commits next frame: the files
save then, as the field-save shortcut does. OsStr::to_str as a method reference (SonarCloud).
2026-10-24 10:31:13 +07:00
bflatastic 3334ec6c16 Batch r2-1 review: vector_up in smaller helpers (SonarCloud cognitive complexity) 2026-10-24 10:31:10 +07:00
bflatastic 8dcba0684b Batch r2-1 review: .fig export grid sizing in helpers; instance layers clear inherited track sizes
auto_layout's grid branch moves to grid_layout and grid_sizing (SonarCloud cognitive complexity).
unset() clears gridColumnsSizing and gridRowsSizing, so an instance layer with equal tracks no
longer reads back its main layer's track sizes.
2026-10-24 10:31:08 +07:00
bflatastic bd80829a27 Batch r2-1 review: a paint keeps its own opacity through unbind and rebind
color_alpha is the part of a solid paint's opacity that is its typed colour's alpha, not a flag:
binding a colour variable takes only that part out, unbinding puts the variable's alpha back in,
and REST import records it. A 50% paint bound to a 50% variable stays at 25% bound or not.
2026-10-24 10:30:49 +07:00
a1a842af9a chore: resolve SonarCloud maintainability issues (#1241)
* chore: resolve SonarCloud maintainability issues

- Define SHA256_FMT constant in packaging script to remove duplicated string literal (shelldre:S1192)
- Remove redundant 'static lifetime annotations from const items (rust:S8863)
- Replace closures with method and function pointers (rust:S1612)
- Extract helper functions in synthetic document generation, bezier polyline flattening, and component restoration to reduce cognitive complexity (rust:S3776)

* fix(packaging): use verify_sha256 helper function to avoid shellcheck SC2059

* fix(engine): remove duplicate closing brace in large_document

* fix(engine): take &mut Document in restore_id_map; keep 'static on associated consts

restore_id_map allocates ids, so it needs a mutable document. Elided
lifetimes in associated consts trip elided_lifetimes_in_associated_constant,
so those annotations are restored.

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

* Assign positional parameters to locals in sha256 verification

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Dylan Taylor <[email protected]>
2026-10-23 23:28:13 -04:00
Logan Jennings 3c2da837f0 engine: stash only closed tabs for group rollback instead of cloning every tab 2026-10-23 22:18:34 -05:00
bflatastic 3b39f7cb3b #698: installing an API 4 plugin from a directory says it may offer files to save; the save strings in every catalog 2026-10-24 10:18:12 +07:00
NetterSilverandClaude Sonnet 5.5 8c08035b89 fix: write non-finite numbers as 0 instead of refusing to save
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 05:13:06 +02:00
bflatastic 551d2b9db6 #698: with plugin API 4 here, "4.0.0" is a valid api version string; the refused one is 5.0.0 2026-10-24 10:12:40 +07:00
bflatastic b35a4e85ab Merge main and the community batch into #698
# Conflicts:
#	README.md
#	crates/engine/src/cmd/file.rs
#	crates/plugin/src/manifest.rs
2026-10-24 10:12:35 +07:00
bflatastic 4babe5a04a #1209: everything that opens or closes the left panel does so on a phone too
UiState::open_left_panel / close_left_panel set the narrow window's panel along with left_collapsed:
the Assets panel (Shift+I), libraries, file and layer rename, page select, version history, the rail
tabs, the panel's Collapse button and panel.leftSidebar (which toggles what a narrow window shows).
Test: view.assets opens the left panel in a phone-sized window.
2026-10-24 10:11:02 +07:00
bflatastic 4667a7dbd2 #966: drop the five [menu] wordings that read like their command ([commands] covers them) 2026-10-24 10:10:53 +07:00
bflatastic ae35d1d794 #966: drop the translation-validation notes (not part of the change) 2026-10-24 10:10:52 +07:00
bflatastic 12bcc715ef Merge main and the community batch into #966 2026-10-24 10:10:32 +07:00
bflatastic a44bba56a0 #703: Cmd/Ctrl-resize crops a layer whose size was bound to a variable as well: the resize detaches the binding (#809), so the new size sticks 2026-10-24 10:09:28 +07:00
bflatastic caeab4e89e #703: an explicit size edit detaches a binding made under the short names w and h too, as it does width and height 2026-10-24 10:09:27 +07:00
bflatastic 8f51875700 #1209: the open panel button's icon uses accent_on, and frame() keeps its doc comment
The icon was painted accent_text on the accent fill (green on green); the inserted fn had taken
frame()'s doc line.
2026-10-24 10:08:50 +07:00
bflatastic ad6af0c489 Merge main and the community batch into #1209
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/toolbar.rs
2026-10-24 10:08:32 +07:00
bflatastic 86e39cc9c0 #703: a Cmd/Ctrl-resize crop whose coefficients aren't finite leaves the image fill as it is 2026-10-24 10:04:32 +07:00
bflatastic ea77e7c38f Merge main and the community batch into #703
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-24 10:03:58 +07:00
bflatastic 2912222c53 #607: the outline-stroke tests join the engine's one integration test binary (tests/it) 2026-10-24 10:02:53 +07:00
bflatastic 07b3daa738 Merge main and the community batch into #607
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
2026-10-24 10:01:40 +07:00
bflatastic 839e9fa4bc #623: the colour scheme watcher wakes the window on a change, reads GNOME's 0 (no preference) as light, and falls back to Read on portals before 1.17 2026-10-24 09:59:26 +07:00
bflatastic 92db01a808 Merge main and the community batch into #1017
# Conflicts:
#	ROADMAP.md
2026-10-24 09:57:35 +07:00
bflatastic e596272d5a Merge main and the community batch into #623
# Conflicts:
#	docs/target-app-parity.md
2026-10-24 09:54:39 +07:00
bflatastic 6595974a52 #921: an image layer copied in FigCraft pastes as a layer; another app's image fills a selected layer only when it is newer than our copy 2026-10-24 09:52:54 +07:00
bflatastic 77540d8c73 #921: a pasted image is added on top of the selected layer's fills; the others keep their variables 2026-10-24 09:52:53 +07:00
bflatastic 0f50e60e35 Merge main and the community batch into #921
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-24 09:50:13 +07:00
bflatastic 5186d83418 #1202: fi and nl drop the code-connect hint's old wording (its ArkUI wording is there) 2026-10-24 09:49:26 +07:00
bflatastic 1ed6447797 #1202: a double-click inside an open path's or a line's box doesn't edit it (only a closed shape has an inside) 2026-10-24 09:47:34 +07:00
aiongg c668e21759 PDF: write with craft-libs' krilla, which bounds decoded PNGs (#1240)
* PDF: write with craft-libs' krilla, which bounds decoded PNGs

* PDF: pin craft-libs' krilla at 9f75d3dc
2026-10-23 22:46:21 -04:00
bflatastic 20ffb1f844 #1202: an asset's chosen export format stays when the document changes 2026-10-24 09:43:07 +07:00
bflatastic a8363f062b #1202: look for empty canvas under the pointer only on a triple click 2026-10-24 09:39:53 +07:00
bflatastic 7fad345e48 #1202: builder_poly_tests.rs without a byte-order mark 2026-10-24 09:39:53 +07:00
bflatastic e52be58d46 #1202: drop the editor rules file (.trae) from the change 2026-10-24 09:39:53 +07:00
bflatastic 62a3072134 Merge main and the community batch into #1202
# Conflicts:
#	crates/engine/src/component_codegen.rs
#	crates/engine/src/tools.rs
#	crates/ui-egui/locales/fi.toml
#	crates/ui-egui/locales/nl.toml
#	crates/ui-egui/src/dev_mode_tests.rs
#	crates/ui-egui/src/shortcuts.rs
#	docs/dev-mode-parity.md
#	docs/file-format-parity.md
#	docs/ui-parity.md
2026-10-24 09:39:52 +07:00
bflatastic 1f9effd25d #660: a file saved as Proto.figcraft presents as Proto (titles follow file names on main) 2026-10-24 09:34:40 +07:00
Susiee The Linux UserandGPT-6 Luna c6dd115e78 UI: Route text clipboard writes through egui (#1239)
Co-Authored-By: GPT-6 Luna <[email protected]>
2026-10-24 04:28:02 +02:00
bflatastic f00b62ba2d Merge fix: the server store tests keep each test closed 2026-10-24 09:26:44 +07:00
bflatastic 9fb88b6668 Merge #904: M1: write grid track sizes in .fig export (#465) (@xiaou61)
# Conflicts:
#	docs/file-format-parity.md
2026-10-24 09:24:04 +07:00
bflatastic 489da09905 Merge #740: engine: fill rules as vector.fillRule and vector.reverseLoop (@yelintong) 2026-10-24 09:23:50 +07:00
bflatastic 550c3e8bbb Merge #905: M1: say that the automation roots reach only the headless side (#835) (@xiaou61) 2026-10-24 09:23:48 +07:00
bflatastic fac42785c1 Merge #1158: FreeBSD: package the aarch64 cross build (craft-common-actions#39) (@zuujiscom) 2026-10-24 09:23:45 +07:00
bflatastic ba8b608673 Merge #660: M5: one presentation tab per page of a file (@JimKnoxx)
# Conflicts:
#	docs/prototyping-parity.md
2026-10-24 09:23:43 +07:00
bflatastic 21a334b3ef Merge #976: fix(ui): keep the style picker open while editing a style's colour (@kollakek1) 2026-10-24 09:23:29 +07:00
bflatastic 075f5bfc98 Merge #1197: Regression test for #237 (@skylartaylor) 2026-10-24 09:23:26 +07:00
bflatastic 0a99f0fb69 Merge #1032: M8: save-format tests keep their temp folders apart (@MortenHusted) 2026-10-24 09:23:24 +07:00
bflatastic 2577f53e2c Merge #1212: Control server: wait after a failed accept instead of retrying at once (@oracola91) 2026-10-24 09:23:21 +07:00
bflatastic 53b8b531ce Merge #1031: Treat U+2007 FIGURE SPACE as non-breaking in line breaking (@Silvertree2010) 2026-10-24 09:23:18 +07:00
bflatastic 771a0060b6 Merge #1038: Keep literal true/false distinct from variables named true/false (@Silvertree2010)
# Conflicts:
#	crates/doc/src/proto_expr_parse_tests.rs
2026-10-24 09:23:15 +07:00
bflatastic 60e1ef9976 Merge #1041: Keep not <var> from reading back as a variable named 'not …' (@Silvertree2010) 2026-10-24 09:23:02 +07:00
bflatastic a5e8d256e6 Merge #1052: Drop XML-forbidden control characters from live-text SVG export (@Silvertree2010) 2026-10-24 09:23:00 +07:00
bflatastic 3610a7cb12 Merge #1142: Write a final Greek sigma in lower-case text layout (@Silvertree2010) 2026-10-24 09:22:57 +07:00
bflatastic 378c015eb3 Merge #1145: Remove all leftover temp files at server startup (@Silvertree2010)
# Conflicts:
#	apps/figcraft-server/src/store_tests.rs
2026-10-24 09:22:54 +07:00
bflatastic 53e61e2a5d Merge #1146: Guard file generation increments against u64 overflow (@Silvertree2010)
# Conflicts:
#	apps/figcraft-server/src/store_tests.rs
2026-10-24 09:22:42 +07:00
bflatastic 3774f9e51d Merge #1149: Bound the library/plugins close drain with a deadline (@Silvertree2010) 2026-10-24 09:22:28 +07:00
bflatastic 75c1586011 Merge #1153: Match widget data check depth to serde_json's recursion limit (@Silvertree2010)
# Conflicts:
#	crates/engine/src/plugins.rs
2026-10-24 09:22:12 +07:00
bflatastic 52afac49fe Merge #1164: Reject non-finite coordinates in control-channel ui.input (@Silvertree2010) 2026-10-24 09:21:48 +07:00
bflatastic f4541d8b24 Merge #1147: Restrict a pre-existing data directory to 0700 (@Silvertree2010) 2026-10-24 09:21:45 +07:00
bflatastic 70536a6265 Merge #948: Keep a solid paint's own opacity when binding a colour variable (@Silvertree2010) 2026-10-24 09:21:42 +07:00
bflatastic d93b806323 Merge #1151: Never drop Sync events when the live inbox is full (@Silvertree2010) 2026-10-24 09:21:39 +07:00
bflatastic ef6a6c49b2 Merge #1185: Make is_ancestor fail closed on deep or looping parent chains (@Silvertree2010)
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-24 09:21:37 +07:00
bflatastic 82004c6e9c Merge #1187: Cap the issue list returned by Document::validate (@Silvertree2010) 2026-10-24 09:21:24 +07:00
bflatastic 15fd9fe1bf Merge #1192: Strip bidi control characters from names and report reasons (@Silvertree2010)
# Conflicts:
#	apps/figcraft-server/src/store_tests.rs
2026-10-24 09:21:04 +07:00
bflatastic 6b37d779e0 Merge #1195: Accept a version string for the plugin manifest api field (@Silvertree2010) 2026-10-24 09:20:03 +07:00
bflatastic fed124fa77 Merge #1238: Fix duplicate SVG ids when a layer name matches a generated id (@Silvertree2010)
# Conflicts:
#	crates/svg/src/tests.rs
2026-10-24 09:20:00 +07:00
bflatastic f109945c47 Merge #1165: Make SVG unique_id linear for repeated layer names (@Silvertree2010) 2026-10-24 09:19:41 +07:00
bflatastic 404916a949 Merge #1169: Refuse library versions that leave no newer one to publish (@Silvertree2010) 2026-10-24 09:19:38 +07:00
bflatastic b5a8dcb059 Merge #1230: Honour even-odd fill rule when hit testing vectors (@Silvertree2010)
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-24 09:19:18 +07:00
bflatastic fcace33059 Merge #1178: Enforce per-address storage caps per IPv6 /64, /56 and /48 (@Silvertree2010) 2026-10-24 09:18:28 +07:00
bflatastic 45a3069dce Merge #1175: Keep the hello size limit until a connection is admitted (@Silvertree2010) 2026-10-24 09:18:25 +07:00
bflatastic f0a7037a53 Merge #1183: Enforce an absolute pre-hello deadline against ping slowloris (@Silvertree2010) 2026-10-24 09:18:22 +07:00
bflatastic d40ec98ef6 Merge #1182: Shut kicked and removed connections when their close can't be queued (@Silvertree2010) 2026-10-24 09:18:19 +07:00
bflatastic 00074d0041 Merge #1186: Stop stale next_id from making pastes overwrite existing nodes (@Silvertree2010) 2026-10-24 09:18:16 +07:00
bflatastic dc0ce6c176 Merge #1181: Refuse text.loadFont path for plugins (@Silvertree2010) 2026-10-24 09:18:13 +07:00
bflatastic 2a20a4de0a Merge #1190: Refuse non-local library paths in the library Cache (@Silvertree2010) 2026-10-24 09:18:10 +07:00
bflatastic e3c9d3f644 Merge #1191: Deny library.publishToServer to plugins (@Silvertree2010) 2026-10-24 09:18:07 +07:00
bflatastic 243728b758 Merge #1196: Don't run the expiry sweep from offline CLI commands (@Silvertree2010) 2026-10-24 09:18:03 +07:00
bflatastic c7062db387 Merge #1171: Never overwrite a file's log when it can't be read (@Silvertree2010) 2026-10-24 09:17:59 +07:00
bflatastic ad5d502200 Merge #1014: M7: keep keyboard focus inside the Interface size menu (@YarilaFox) 2026-10-24 09:17:56 +07:00
bflatastic f3782e6bc4 Merge #1023: fix(engine): show the Pen's Shift 45° step in its preview line (#970) (@vxMacDev) 2026-10-24 09:17:11 +07:00
bflatastic eada8b0ff3 Merge #1042: M3: style links given in a call apply after its values (@MortenHusted)
# Conflicts:
#	crates/engine/src/cmd/props.rs
2026-10-24 09:16:54 +07:00
bflatastic 90e5e03b23 Merge #1160: M0.9: figcraft-cli export names files as the app's export does (#1154) (@switch87) 2026-10-24 09:16:12 +07:00
bflatastic adc8e81826 Merge #1161: M0.9: --help and -h after a figcraft-cli subcommand print the usage (#1155) (@switch87) 2026-10-24 09:16:07 +07:00
bflatastic 15321a56ff Merge #1198: fill/stroke props: accept 'none' and null as no paint (@lileeei)
# Conflicts:
#	ROADMAP.md
2026-10-24 09:16:04 +07:00
bflatastic 1e4a31a303 Merge #1199: M3: instance-swap properties in the Dev Mode component playground (@marcoarnulfo) 2026-10-24 09:15:41 +07:00
bflatastic 6063d3e9a9 Merge #1189: M4: vector edit transform box around points of several layers (@marcoarnulfo)
# Conflicts:
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-24 09:15:11 +07:00
bflatastic 8f9e2fedc7 Merge #1047: M7: Type settings keeps its width with long labels (#1029) (@AganzeFelicite)
# Conflicts:
#	ROADMAP.md
2026-10-24 09:14:10 +07:00
bflatastic 07191c881d Merge #1030: M1: individual corner fields sit at their corners, each with its own icon (#1002) (@muamerETS) 2026-10-24 09:13:48 +07:00
bflatastic 8eb5624460 Merge #986: fix: = zooms in, matching - zooming out (#973) (@Ridzzz0Alam) 2026-10-24 09:13:43 +07:00
bflatastic f31199d4ca Merge #1018: M1: a selected unfilled shape drags from inside its box (#910) (@muamerETS) 2026-10-24 09:13:38 +07:00
bflatastic 370faa52da Merge #1033: M3: a variant switch takes the new variant's resizing unless the instance set its own (@MortenHusted) 2026-10-24 09:13:33 +07:00
bflatastic ab5a62387b Merge #1034: M6: SVG import fills open subpaths the way SVG does (@MortenHusted) 2026-10-24 09:13:28 +07:00
bflatastic 98040b48a5 Merge #1037: M3: a variable mode set on a layer inside an instance is an override (@MortenHusted) 2026-10-24 09:13:23 +07:00
bflatastic 83f840f802 Merge #1043: M3: a swapped nested instance keeps its outer main's overrides (@MortenHusted) 2026-10-24 09:13:18 +07:00
bflatastic d394e952c6 Merge #1044: M4: individual stroke weights follow the corner radius like CSS borders (@MortenHusted) 2026-10-24 09:13:13 +07:00
bflatastic ac5783d0cd Merge #1135: M5: prototype actions lay out what they change like the canvas does (@MortenHusted)
# Conflicts:
#	docs/prototyping-parity.md
2026-10-24 09:13:08 +07:00
bflatastic 293ab33eaa Merge #1166: M7: Cmd/Ctrl+S saves while a panel field has the keyboard (#1157) (@switch87) 2026-10-24 09:12:49 +07:00
bflatastic 90f401d73c Merge #1173: M8: the unsaved-changes question on close offers Save (#1170) (@switch87) 2026-10-24 09:12:45 +07:00
bflatastic 2053098e8e Merge #1200: The colour picker's eyedropper applies the colour to the paint the picker edits (Reported in storytold/vectorcraft#3342) (@marcoarnulfo)
# Conflicts:
#	crates/engine/src/tools.rs
2026-10-24 09:12:42 +07:00
bflatastic 2c9f5267a9 Merge #1232: M5: a top-level frame taller than the device scrolls without an overflow setting (@seltzdesign) 2026-10-24 09:11:46 +07:00
bflatastic 703d55cc67 Merge #983: Fix invisible dropdown options in the light theme (@Ridzzz0Alam) 2026-10-24 09:11:41 +07:00
midasdf 18832c620e Merge remote-tracking branch 'origin/main' into jp/kinsoku-jis
# Conflicts:
#	ROADMAP.md
#	docs/target-app-parity.md
2026-10-24 09:44:03 +09:00
zuujiscomandClaude Opus 5.5 5d9f8a53a3 Merge main into freebsd/aarch64-cross-package
Resolves the package.sh conflicts with main's SonarCloud cleanup: the cross-build changes are kept
and use main's style, with [[ ]] tests and release_arch taking its argument as a named local and
ending in an explicit return.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:06:00 -05:00
vetlucasmartins 7a2568fe55 tailwind: emit @theme static; fix assertion precedence in tests
Tailwind v4 drops theme variables no utility uses, but page code also
reads tokens through var() and (--name) utilities, so the export keeps
them all. Two test assertions mixed && and || without parentheses.
2026-10-24 00:50:53 +01:00
vetlucasmartins 788d1df7be Merge remote-tracking branch 'origin/feat/css-export-px' into feat/tailwind-output 2026-10-24 00:47:30 +01:00
vetlucasmartins 90455cc429 token roles: follow alias chains to their end
variable.set does not cap alias chains, so propagate until a pass adds
nothing instead of a fixed eight rounds.
2026-10-24 00:43:59 +01:00
vetlucasmartins 3734965cce Merge remote-tracking branch 'upstream/main' into feat/css-export-px 2026-10-24 00:41:08 +01:00
vetlucasmartins 3a3746d45d Merge upstream/main into feat/data-fill 2026-10-24 00:33:14 +01:00
vetlucasmartins 601a9701a5 text.fit: keep sizes that fit, respect the floor, skip one-word paragraphs
- Text that already fits keeps its exact size; shrunk sizes round to a
  tenth but never below minSize (per run, its share of it).
- A last line after a hard break is not an orphan, and the no-break space
  is only placed inside the last paragraph.
- docs: data.fill documents formats? and errors?.
2026-10-24 00:32:24 +01:00
vetlucasmartins cf4119dd0c Merge remote-tracking branch 'upstream/main' into feat/data-fill 2026-10-24 00:25:24 +01:00
NetterSilverandClaude Haiku 5.5 6494743b6e Fix duplicate SVG ids when a layer name matches a generated id
Found while testing crates/svg/src/lib.rs:549 (unique_id) with :162 (id) and :387

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:55:35 +00:00
ArminandClaude Opus 5.5 95db91def9 M5: a top-level frame taller than the device scrolls without an overflow setting
On a device, a presented frame bigger than the screen now scrolls through
its whole size whatever its overflow, as Figma documents for top-level
frames. Before, "No scrolling" frames only clipped, so the landing page
template's long Phone frame couldn't be scrolled in Present. Content past
a frame's own edges still scrolls only on the axes its overflow allows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 00:04:28 +02:00
BFlat 8167f13a6a M12: Boards: a collaborative whiteboard file kind (#1229)
Board files (File > New board) with sticky notes, shapes with text, connectors that follow what they join (straight or elbow, arrowheads, labels), marker, highlighter and eraser, and stamps; a board toolbar and a contextual bar; the Design panel hidden on boards, Prototype and Dev Mode refused there. Boards are ordinary FigCraft files built from ordinary layers, so saving, undo, export, MCP and live sessions work unchanged. File tabs, recent files and Open files show a pen nib for design files and a note for boards (original icons drawn in code).
2026-10-24 04:18:55 +07:00
Dylan M. Taylor a4f25b0733 ci(sonar): don't cancel superseded runs on main, so coverage is published (#1231) 2026-10-23 17:01:52 -04:00
BFlat 17ed21d128 M6: Dev Mode tail: imports in layer trees, compare with versions, an inspect-only canvas (#538)
Import lines for connected components inside generated layer trees; Compare changes against any saved version; a Dev Mode behaviour suite with an inspect-only canvas (menus, shortcuts, paste and Revert to saved refuse design edits; a drag under way is dropped on entering), Changed in the ready list, statuses only on top-level frames and sections; read-only plugins' panels from the Inspect tab.
2026-10-24 02:56:55 +07:00
NetterSilverandClaude Haiku 5.5 068634b586 Honour even-odd fill rule when hit testing vectors
Found while testing crates/doc/src/geometry.rs:75 (hits_node_itself, the `_` arm, using path.winding(lp) != 0); compare VectorNetwork::fill_contains at crates/doc/src/vector.rs:321

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 21:53:25 +02:00
aiongg 70f702ca17 i18n: a missing translation fails no check; cargo xtask i18n-report lists them (#1228) 2026-10-23 15:43:21 -04:00
midasdf 04e60e23bc Merge remote-tracking branch 'origin/main' into jp/kinsoku-jis
# Conflicts:
#	ROADMAP.md
#	docs/target-app-parity.md
2026-10-24 04:11:13 +09:00
BFlat d6e8a6e874 Docs: roadmap entries for the community PRs in #507, #510, #529 and #530 (#587)
Roadmap, milestone and parity rows for the 93 community PRs landed in batches #507, #510, #529 and #530.
2026-10-24 02:02:19 +07:00
aiongg 87e0c8a7da CI: shared gate workflow; FreeBSD nightly and on release (#1206)
* CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release

ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.

* CI: build the shared gate without craft-fonts, as before

* CI: build the shared gate with release.yml's craft-fonts pin
2026-10-23 14:31:08 -04:00
saturngod 95d4a1e6ce Merge commit '6ba02236367320fef0b373b461f6e9796bfc6de0' into fix/boolean-stroke-outlines 2026-10-23 13:02:40 -05:00
RicardoandClaude Opus 5.5 c19b7d0799 .fig export: a property name used with two types in a set writes both definitions
The set wrote one definition per name, so a variant whose property of
that name had another type named a definition the file didn't hold
(CodeRabbit review on #1152).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:00:22 -06:00
saturngod 690f31719d Merge commit 'c48102350b1ef00614b105ac07451ff0ca8a5141' into fix/boolean-stroke-outlines 2026-10-23 12:51:13 -05:00
aiongg 6ba0223636 Tests: the paragraph indent test loads system fonts and skips only without glyphs (#1227) 2026-10-23 13:50:42 -04:00
aiongg c48102350b Packaging: pin the signing client and appimagetool, and check their hashes (#950)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Reject AppImage download for architectures without a pinned appimagetool

* Pin the AppImage runtime
2026-10-23 13:02:11 -04:00
aiongg fc7a744519 CI: one test binary per crate, nextest, line tables only (#1208)
* Dev profile: line tables for workspace crates, no debug info for dependencies

* Tests: one integration-test binary per crate with several test files

* xtask ci: nextest, doctests, tests without examples, --keep-going; add xtask test
2026-10-23 13:01:12 -04:00
Dylan M. Taylor e66780a39b packaging: use [[ for bash conditional tests (SonarCloud S7688) (#1225)
SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests.
2026-10-23 12:56:34 -04:00
Dylan M. Taylor 8d790607ff packaging: bash test and function hygiene (SonarCloud S7688, S7679, S7682, S131, S7677) (#1201)
- Use [[ ]] instead of [ ] for conditional tests in the bash packaging scripts (S7688). Every
  converted test compares quoted values to literals, so no pattern-matching change.
- Name positional parameters with locals inside functions (S7679).
- End the env.sh helpers with an explicit return that keeps their exit status (S7682).
- Give case statements a default branch (S131).
- Send the check-app-specific.sh error to stderr (S7677).

packaging/freebsd/package.sh is left alone while #1158 rewrites it.
2026-10-23 11:17:32 -04:00
CosmoNetinfoandClaude Sonnet 5.5 98b90b3c56 Add Italian (it) interface language
Adds crates/ui-egui/locales/it.toml: all 1747 UI strings (42 plural entries,
one/other), 574 command labels and 31 menu wordings, marked as a draft awaiting
native review like the other machine-drafted catalogs. Adds a resolve/plural
test for Italian locales and lists Italian in docs/translating.md.

Not compiled locally; the catalog was validated with a script (same keys as
en.toml, identical placeholders and ellipses per entry).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fr2xzM8A8RiUwZWddELBCt
2026-10-23 16:49:31 +02:00
Dylan M. Taylor 92b83521af Merge pull request #1221 from dylanmtaylor/sonar/security-reliability
test: explain the discarded reads in the fake servers (SonarCloud rust:S7450)
2026-10-23 09:57:08 -04:00
Dylan M. Taylor 32735549c4 test: explain the discarded reads in the fake servers (SonarCloud)
read() returns Result<Message>, not a lock guard; these test loops
deliberately ignore what the client sent first.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-23 09:44:02 -04:00
Salah1StandClaude Opus 5.5 f728b8f9fe M7: test that text.create and the Text tool start in DEFAULT_FONT
Review: the doc test serialised TextStyle directly. The engine test makes
text with text.create (no fontFamily) and with a Text tool click, and
checks each starts in figcraft_doc::DEFAULT_FONT and writes it with the
text. Passes with FIGCRAFT_DEFAULT_FONT unset and set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:07:40 +07:00
Salah1StandClaude Opus 5.5 dd37c1763c M4: the Thai fallback test checks the characters it draws
Review: the test chose its Thai face, and checked each glyph's face, by
U+0E01, which the sample doesn't contain. It now asks for a face that has
every Thai character of the sample, and checks that each glyph's face has
the characters of its cluster.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:36:24 +07:00
Salah1StandClaude Opus 5.5 c4be9c1a34 M7: say what a default font family in files does and doesn't promise
Review: a saved family renders only where that family is available. The
docs and the test's comment now say the variable changes only what new
text starts in, and that a file keeps its fonts in a build with another
default, falling back like any missing family elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:34:43 +07:00
oracola91 42502a81d7 Control server: wait after a failed accept instead of retrying at once
The accepting thread read listener.incoming().flatten(), which drops a
failed accept and asks again at once. While accepting keeps failing, for
example when the process has run out of file descriptors, that thread
kept a core busy. It now waits 100 ms after a failed accept. The loop goes
through a small accepted helper so the wait can be tested.
2026-10-23 11:59:36 +03:00
Marco Arnulfo 2a6e460a7b Let the box of another layer's points win over a region of the active one
With points selected only on another edited layer, a press inside the
combined box over an unselected filled region of the active layer picked
the region instead of dragging the box. Other layers' selections now count
in the same priority check; a press on an already selected region still
drags the regions.
2026-10-23 10:17:56 +02:00
aryans2611andClaude Sonnet 5.5 ea03f90aaf M6: mobile: test that the toolbar pill fits a phone canvas; rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 13:09:25 +05:30
Salah1StandClaude Opus 5.5 b649035695 M7: FIGCRAFT_DEFAULT_FONT names the font new text starts in
TextStyle::default() hard-coded Inter, which has no Thai (nor Hebrew,
Devanagari, ...). In a build for users who mostly write such a script,
every new text layer started in a font that can't draw what they type:
fallback faces hide the boxes, but the layer still says Inter and gets
Inter's metrics and line height.

Like FIGCRAFT_DEFAULT_SERVER, a build variable read with option_env!
names another family; unset or empty means Inter. font_family is always
written, so files open in the same font whichever build reads them.
docs/releasing.md gets a "The default font" section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:28:29 +07:00
Salah1StandClaude Opus 5.5 a51d3eed53 M7: Noto Sans Thai as a UI fallback when the build embeds it
In the web build, Thai layer names, frame labels on the canvas, text in
property fields and the layer search were drawn as missing-glyph boxes,
even when the build embedded Noto Sans Thai: the UI's built-in fallback
list (BUILT_IN in theme::install_fonts) didn't name it, and the browser
has no system fonts to fall back on.

Noto Sans Thai joins BUILT_IN. It is used only when the face is
embedded, so builds without it are unchanged. Test: with it embedded,
the UI fonts draw Thai, marks included (skips without it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:27:21 +07:00
Salah1StandClaude Opus 5.5 1340e32b16 M4: Thai faces in the text fallback list
Thai typed into a layer whose font has no Thai glyphs (Inter, which new
text starts in) was drawn as missing-glyph boxes, on the desktop and on
the web, even with a Thai face embedded or installed: FALLBACK_FAMILIES
listed Latin, CJK, Hangul, Arabic and symbol families but no Thai one.

Noto Sans Thai, Noto Sans Thai UI, Leelawadee UI, Thonburi and Tahoma go
after Noto Sans Arabic; they are only consulted for characters the
layer's own font lacks. Tests: the list has a Thai face for each
platform, and with one installed or embedded every Thai cluster in a
Latin layer, marks included, is drawn from it (skips without one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:26:37 +07:00
aryans2611andClaude Sonnet 5.5 ab375119ad M6: mobile: submenus stay on screen, toolbar hides while a side panel is open
A submenu with no room beside its row (a phone) opens over its parent menu instead of off the
right edge. The toolbar pill is not drawn while a narrow window shows a side panel, where it was
squeezed into the strip left beside the panel.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:36:32 +05:30
aryans2611andClaude Sonnet 5.5 0289890197 M6: mobile layout: one side panel at a time on phones, two-finger pan, scrolling toolbar
Windows narrower than 760 pt show the Layers or Design panel over the canvas, opened from two
touch-sized buttons. Two fingers pan as well as pinch. The main toolbar pill is capped to the
canvas width and scrolls sideways (drag or wheel) when its buttons don't fit; the help button
moves above it when they would overlap. The web page's viewport covers the safe area and stops
touch scrolling and overscroll.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:36:31 +05:30
Nicek 261b8c5146 fix: 处理 PR 评审意见(剪贴板、图案填充、ArkUI 生成、资产缩略图)
粘贴其他应用的设计图层时保留已复制的填充/描边/效果行;粘贴重复图案前校验来源,拒绝自引用;ArkUI 生成补上变体枚举导出、对齐文本的宽度、auto layout 内绝对定位子节点,并用容器包裹带定位的 instance swap builder;资产列表只为可见行生成缩略图;Shift+Del 检查所有编辑图层的选中点;文档把 ArkUI 标为 FigCraft 自有能力并更新状态与修订历史。
2026-10-23 13:20:31 +08:00
phonedu c79bc04a29 M6: default Vue instance swaps and keep text out of attribute detection
A Vue component's instance-swap prop now defaults to its component reference in withDefaults, so the dynamic component renders the design default. Attribute detection only applies inside an unfinished opening tag, so text like `Total = {title}` stays text instead of becoming a quoted attribute value.
2026-10-23 10:48:21 +08:00
RicardoandClaude Opus 5.5 4c6c4d9667 .fig export: warn when a set's variants disagree on a property default
The format keeps one default per property of a set; the first variant's
is written (CodeRabbit review on #1152).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:46:12 -06:00
RicardoandClaude Opus 5.5 19d6c17b6d .fig export: grid track sizes
Grids wrote their tracks as equal ones, with a warning. Track sizes
(fixed px, hug, fill shares) are now written as gridColumnsSizing /
gridRowsSizing, the fields the import already reads.

Checked on a synthetic grid in the incumbent (fixed 320/140/112/34/34 px
columns): written as equal tracks it came in with five equal columns and
its layers overflowing their cells; now its columns keep their sizes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:43:55 -06:00
Nicek 1ad244d9e9 Merge remote-tracking branch 'origin/main' into local/vector-edit 2026-10-23 10:34:37 +08:00
phonedu 878e299724 M6: keep Vue attribute context and escape plain attribute entities
A quoted Vue attribute stays open across placeholders, so `title="{a} {b}"` escapes both values as attribute text. Plain Vue attribute values in instance calls now HTML-escape `&`, `<` and `>`, so a literal `&amp;` survives the round trip. Tests cover both.
2026-10-23 10:29:46 +08:00
Marco Arnulfo 410c1f10e2 The colour picker's eyedropper applies the colour to the paint the picker edits (Reported in storytold/vectorcraft#3342) 2026-10-23 04:08:48 +02:00
Nicek 5b97d03701 fix: 修复异形描边生成重叠区域的问题
通过将自相交路径简化为简单轮廓,解决了中心描边在异形尖角处产生重叠区域的问题。新增clean_poly函数清理多边形中的重复点和亚像素细条,修改路径处理逻辑确保生成的区域不重叠,同时添加相关测试验证正确性。
2026-10-23 09:49:32 +08:00
Marco Arnulfo af0b150736 M3: instance-swap properties in the Dev Mode component playground 2026-10-23 03:28:55 +02:00
skylar taylor-barrick 3b440d01e6 #237: test that wrapped colour picker swatches stay inset inside the picker
The "On this page" swatches were indented by a single add_space, so once they wrapped the later rows started at the picker's left edge and ran past its right edge. The test fills a page with 40 distinct colours, opens the picker and checks every painted swatch lies at least 16 pt inside both sides of the picker window. Without the fix the rightmost swatch ends 3 pt inside the edge instead of 16.
2026-10-22 18:39:53 -06:00
NetterSilverandClaude Haiku 5.5 49c0d4c789 Don't run the expiry sweep from offline CLI commands
Fixes #1091

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:17:35 +00:00
NetterSilverandClaude Haiku 5.5 ffe61d1880 Accept a version string for the plugin manifest api field
Fixes #1040

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 00:13:46 +02:00
NetterSilverandClaude Haiku 5.5 375001983c Strip bidi control characters from names and report reasons
Fixes #1126

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:07:07 +00:00
NetterSilverandClaude Haiku 5.5 b57c67782f Deny library.publishToServer to plugins
Fixes #1055

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:54:59 +02:00
NetterSilverandClaude Haiku 5.5 b3e65c89f9 Refuse non-local library paths in the library Cache
Fixes #1056

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:46:52 +02:00
Marco Arnulfo 1f2a8104bf M4: vector edit transform box around points of several layers
With points selected in more than one edited layer the transform box now
wraps them all, in the page's axes since the layers can be turned
differently. Its corners and edges scale them, outside a corner rotates
them and inside drags them; each layer's points are mapped in its own
coordinates and the whole drag is one undo step. A click inside clears
every layer's points.
2026-10-22 23:37:49 +02:00
NetterSilverandClaude Haiku 5.5 c857f8d0f3 Cap the issue list returned by Document::validate
Fixes #1058

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:30:11 +02:00
NetterSilverandClaude Opus 5.5 8aa456b198 Stop stale next_id from making pastes overwrite existing nodes
Fixes #1064

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:23:47 +02:00
NetterSilverandClaude Haiku 5.5 5a53425f51 Make is_ancestor fail closed on deep or looping parent chains
Fixes #1066

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:14:57 +02:00
NetterSilverandClaude Haiku 5.5 d5de4be1ab Enforce an absolute pre-hello deadline against ping slowloris
Fixes #1068

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:08:25 +02:00
saturngod 6cdc36f6c3 Fix stroke-only boolean operand outlines 2026-10-22 16:07:38 -05:00
zuujiscom 03354ec7d3 FreeBSD package.sh: accept only the two supported FreeBSD targets
Review follow-up. CARGO_BUILD_TARGET must be aarch64-unknown-freebsd or x86_64-unknown-freebsd,
the two the release builds; anything else, including made-up triples such as
aarch64-foo-unknown-freebsd, stops with exit 2.

Part of storytold/craft-common-actions#39.
2026-10-22 16:06:19 -05:00
NetterSilverandClaude Haiku 5.5 c80d1e7ae7 Shut kicked and removed connections when their close can't be queued
Fixes #1070

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:04:22 +02:00
NetterSilverandClaude Haiku 5.5 b8741162cd Refuse text.loadFont path for plugins
Fixes #1071

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:57:58 +02:00
NetterSilverandClaude Opus 5.5 d7d6bc8429 Enforce per-address storage caps per IPv6 /64, /56 and /48
Fixes #1073

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:49:58 +02:00
Gert Pellin d77e002170 M8: one Save goes through every file needing Save as; Enter answers Save (#1170)
Review: after a Save as, closing called request_close, which asked the
question again while another file still had changes. It now carries on
with save_then_close. Save is the default answer: Enter picks it when no
button has the keyboard.
2026-10-22 22:35:42 +02:00
zuujiscom 30149d765d FreeBSD package.sh: never run a FreeBSD target's binary on another OS
Review follow-up. With CARGO_BUILD_TARGET set on a host that is not FreeBSD (say an x86_64 Mac
building x86_64-unknown-freebsd), the architectures match but the binary still cannot run, so
the --version run is skipped there and the file(1) check is used instead.

Part of storytold/craft-common-actions#39.
2026-10-22 15:31:37 -05:00
NetterSilverandClaude Opus 5.5 4f39406fcd Keep the hello size limit until a connection is admitted
Fixes #1087

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:30:07 +02:00
Gert Pellin 83dac86bc5 M8: the unsaved-changes question on close offers Save (#1170)
Closing a window with unsaved changes asked "Close the window and lose
them?" with only Close without saving and Cancel. It now asks
Don't Save / Cancel / Save, as PhotoCraft and VectorCraft do. Save saves
each file of the window with unsaved changes and closes; a file without
a path (or saved as .fig) gets Save as first, and closing goes on once
that saved it. A failed save or a cancelled Save as keeps the window
open with its changes. The two changed strings are updated in every
catalog.
2026-10-22 22:23:11 +02:00
NetterSilverandClaude Opus 5.5 eafa1a1d06 Never overwrite a file's log when it can't be read
Fixes #1090

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:20:49 +02:00
NetterSilverandClaude Haiku 5.5 51a4509c87 Refuse library versions that leave no newer one to publish
Fixes #1092

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:16:23 +02:00
Gert Pellin d9cb5cd216 M7: the save-from-field test answers Save as and checks both files (#1157) 2026-10-22 22:05:36 +02:00
zuujiscom 477b25e63f FreeBSD package.sh: accept only executables in the cross-build check
Review follow-up. The file(1) check for binaries that cannot run here now needs an executable
(file says "executable" or "pie executable") or an ELF with an interpreter, which is how an older
file(1) still shows a PIE it calls a shared object. FreeBSD libraries, object files and core files
for the target architecture are refused.

Part of storytold/craft-common-actions#39.
2026-10-22 14:49:35 -05:00
NetterSilverandClaude Haiku 5.5 b5e5c2100e Make SVG unique_id linear for repeated layer names
Fixes #1106

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:48:28 +02:00
Gert Pellin 8f0982b5bd M7: Cmd/Ctrl+S saves while a panel field has the keyboard (#1157)
With the keyboard in a Design panel field (X, W, a colour…), the
shortcut handler returned before looking at any key, so Cmd/Ctrl+S and
Shift+Cmd/Ctrl+S did nothing: no save, no message, the tab kept its
unsaved dot. Those two now work from a field: the field lets go of the
keyboard, which commits what was typed as clicking away does, and the
save runs the next frame.
2026-10-22 21:46:32 +02:00
Gert Pellin c7d2a5f019 M9: the symbol test also looks for the Noto Sans Symbols2 spelling (#1156) 2026-10-22 21:46:12 +02:00
Gert Pellin bb2e56a7ea M0.9: --help wins over an earlier bad option (#1155)
Review: `mcp --bogus --help` stopped at --bogus with exit 2. The parser
now keeps the first bad option and reports it only when no --help or -h
follows.
2026-10-22 21:45:29 +02:00
Gert Pellin eb07717783 M0.9: trim mixed edge spaces and dots, document the name limits (#1154)
Review: " . .name. . " kept inner dots at the edges; trim spaces and dots
in one pass. docs/cli.md and safe_file_name's comment give the 100 and
200 character limits.
2026-10-22 21:43:52 +02:00
Brandon ThomasandClaude Opus 5.5 80f916326f Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:39:04 -04:00
NetterSilverandClaude Haiku 5.5 c6dba8c8f3 Reject non-finite coordinates in control-channel ui.input
Fixes #1112

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:34:58 +02:00
zuujiscom e366f61e8f FreeBSD package.sh: accept only FreeBSD targets, check cross-built binaries
Review follow-up. CARGO_BUILD_TARGET must now be a *-unknown-freebsd triple, so a Linux or macOS
target can no longer be packaged under a FreeBSD name, and it is read before uname -m, so an
unsupported host does not block a valid cross build. When the binaries are for another
architecture and cannot run --version, file(1) must report each as a FreeBSD ELF executable
for the target architecture.

Part of storytold/craft-common-actions#39.
2026-10-22 14:32:12 -05:00
Gert Pellin 5a3dddc46f M9: fall back to the Noto symbol and math fonts on Linux (#1156)
Signs such as U+2316 and U+2912 are in no text face. The fallback list
had the macOS and Windows symbol fonts (Apple Symbols, Segoe UI Symbol)
but nothing a Linux install has, so they drew as empty boxes there even
with Noto Sans Symbols 2 or Noto Sans Math installed. Adds Noto Sans
Symbols, Noto Sans Symbols 2 (and the Symbols2 spelling some packages
use) and Noto Sans Math after them.
2026-10-22 21:29:58 +02:00
Gert Pellin 40315a123d M0.9: --help and -h after a figcraft-cli subcommand print the usage (#1155)
`figcraft-cli mcp --help` failed with "unknown option --help" and
exit code 2; only a leading --help worked. The argument parser now
returns CliError::Help for --help or -h among a subcommand's options
(not as an option's value), and main prints the usage on stdout with
exit code 0. docs/cli.md says so.
2026-10-22 21:25:24 +02:00
Gert Pellin ca30791fd7 M0.9: figcraft-cli export names files as the app's export does (#1154)
export into a folder replaced every character outside letters, digits,
'-', '_', space and '.' with '_', so a frame called
"Homepage - desktop (live + verbeteringen)" became
"Homepage - desktop _live _ verbeteringen_.png". The app's
file.exportLayers keeps those names and only replaces what file systems
refuse. The CLI now uses the engine's safe_file_name, and docs/cli.md
says which characters are replaced.

Also adds my own entry to contributors/people.toml.
2026-10-22 21:22:20 +02:00
RicardoandClaude Opus 5.5 2c044f6b80 .fig export: an override that clears a maximum size writes it as infinite
Like an unset axis, a cleared maximum was written as 0 and could
collapse the layer (CodeRabbit review on #1152).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 13:16:16 -06:00
zuujiscom 27ea0d4bdc FreeBSD package.sh: package the aarch64 cross build
The release workflow now cross-compiles FreeBSD aarch64 inside the x86_64 VM
(storytold/craft-common-actions#50) and sets CARGO_BUILD_TARGET=aarch64-unknown-freebsd.
package.sh now reads the binaries from target/<triple>/release when CARGO_BUILD_TARGET
is set, names the archive after the target instead of uname -m, and skips the --version
run when the binary is for another architecture than the machine. x86_64 packages are
unchanged.

Part of storytold/craft-common-actions#39.
2026-10-22 14:14:09 -05:00
NetterSilverandClaude Haiku 5.5 67ae35b155 Match widget data check depth to serde_json's recursion limit
Fixes #1118

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:11:15 +02:00
NetterSilverandClaude Opus 5.5 7e114c3420 Never drop Sync events when the live inbox is full
Fixes #1119

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:03:10 +02:00
NetterSilverandClaude Haiku 5.5 57e0872943 Bound the library/plugins close drain with a deadline
Fixes #1120

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:56:58 +02:00
RicardoandClaude Opus 5.5 70a080af5b .fig export: files Figma imports, with component set properties
Checked by importing synthetic files into the incumbent (Drafts > Import,
black-box) and against a "Save local copy" of a synthetic document.

- Images are named and referenced by the SHA-1 of their bytes. With
  SHA-256 names, any file with an image failed to import ("Internal error
  during import").
- Component sets hold their property definitions, variant properties
  included (stateGroupPropertyValueOrders); each variant has its own copy
  of each definition (parentPropDefId) and its variantPropSpecs, and its
  layers and instances name that copy. Definitions and values also travel
  as varValue; text values carry their lines; layers get componentPropRefs
  next to the parameter consumption map. Before, sets came in without
  variant properties and with their other properties unbound.
- An axis without a maximum size is written as infinite: 0 collapsed
  auto layout frames with only a maximum width.
- Compound weights are spelt the way font families name their faces
  ("SemiBold"; "Semi Bold" in Inter): text in Libre Franklin Semi Bold was
  a missing font there and wasn't drawn.
- The kiwi encoder writes infinite floats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:56:07 -06:00
NetterSilverandClaude Haiku 5.5 8c46b733e6 Restrict a pre-existing data directory to 0700
Fixes #1127

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:43:04 +02:00
NetterSilverandClaude Haiku 5.5 82779ec0f2 Guard file generation increments against u64 overflow
Fixes #1128

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:39:39 +02:00
NetterSilverandClaude Haiku 5.5 21d666465f Remove all leftover temp files at server startup
Fixes #1129

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:34:42 +02:00
NetterSilverandClaude Haiku 5.5 b8a5f76471 Keep context menu open after a same-frame trackpad tap
Fixes #1139

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:29:04 +02:00
NetterSilverandClaude Opus 5.5 83b9f74665 Write a final Greek sigma in lower-case text layout
Found while testing crates/text/src/layout.rs:1070 (shape_segment calls TextCase::apply_after one char at a time; crates/doc/src/text.rs:124 apply_after)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 19:04:10 +02:00
AganzeFelicite be6a547ff4 M7: the Type settings test checks it stays open at 264 px every frame (#1029) 2026-10-22 18:01:15 +02:00
Yarila 2cd88c0f46 Merge branch 'main' into fix/interface-size-menu-focus 2026-10-22 20:05:21 +05:00
lileeei 2437a65d1b M1: fill/stroke props accept "none" and null (#1036)
node.create, node.set and the other prop appliers map fill/stroke
"none" (either case) and null to an empty fills/strokes list, as
fills: [] already did (undocumented), so an outline-only shape is one
call - agents write "none" for no paint. KEYS and docs/mcp.md
document it; the error names the accepted forms. fill_none_tests
covers create, set, undo, text and the error.
2026-10-22 23:04:57 +08:00
MortenHusted ad9116ff5c M5: the prototyping parity status line and history name the layout fix 2026-10-22 17:01:21 +02:00
MortenHusted 3d5f3e7946 M5: prototype actions lay out what they change like the canvas does
Change to rebuilt the instance from the new variant, and set variable
and set variable mode re-applied bindings, but the player never laid the
result out: no text sizing, no auto layout. A hugging button with a
longer label than its main showed the other variant at the main's width,
its label clipped, and a text bound to a variable kept its old size.

These actions now run the engine's normalize on the player's document
afterwards, the pass that follows every edit on the canvas: text resizes
to what it shows, instances follow and auto layout reflows.
2026-10-22 16:41:30 +02:00
F3R9 0f85c2294e M9: avoid read-only snapshot rewrites and duplicate buffers
Validated formatting, workspace Clippy, workspace tests excluding the two pre-existing localized startup assertion failures tracked in #1017, layers, assets, WASM and plugins. The startup tests are unchanged in this independent fix.
2026-10-22 17:40:52 +03:00
Logan Jennings ef4aecfd17 engine: restore tabs closed inside a failed command group 2026-10-22 07:24:22 -07:00
NetterSilverandClaude Haiku 5.5 ddeceb17b3 Capitalise title-case word after an opening apostrophe
Found while testing crates/doc/src/text.rs:138 (TextCase::apply_after; is_word_char at :153 counts ' and U+2019 as word characters)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 13:36:45 +00:00
NetterSilverandClaude Opus 5.5 6d748536b6 Drop XML-forbidden control characters from live-text SVG export
Found while testing crates/svg/src/lib.rs:153 (esc, used for tspan text at :598, :608 and :613)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 13:01:37 +00:00
phonedu 19b0f6a194 test: accept either placement of a nested instance's v-if
The guard may sit on the call or on its placing wrapper; assert it is present, once, and not after a self-closing slash.
2026-10-22 20:44:46 +08:00
Nicek 7e28d58b3f feat: 新增 ArkUI 代码生成和代码连接支持
添加 ArkUI 作为新的代码语言支持,包括:
- 代码生成:支持生成 ArkTS 组件代码,包括 `@Component` 结构体、`@Prop` 参数、`@BuilderParam` 构建器等
- 代码连接:支持将组件连接到 ArkTS 代码,包括导入路径和模板配置
- 测试:添加 ArkUI 相关测试用例,验证代码生成和连接功能的正确性
- 更新文档:更新开发模式 parity 文档、文件格式一致性文档等,添加 ArkUI 相关说明
- 更新国际化:在所有支持的语言中添加 ArkUI 选项提示
2026-10-22 20:36:45 +08:00
phonedu ae92735dae M6: harden Vue attribute and slot output
Keep single-quoted attribute delimiters, render text placeholders as text (not quoted strings), skip a > inside an attribute value when placing v-if (and insert before a self-closing slash), give slot content a per-slot class prefix so repeated layer names stay distinct, use a binding name no generated prop can shadow, and add Vue to the five translations that still listed only the older languages.
2026-10-22 20:28:04 +08:00
phonedu 588b441114 Merge remote-tracking branch 'upstream/main' into pr664-fix 2026-10-22 19:53:01 +08:00
phonedu a132eb3768 M6: fix Vue bindings, instance guards and slot catalogs
Vue connected templates keep a string's JS quotes inside bound attributes, so `:title={title}` becomes `:title="&quot;Buy&quot;"` rather than a bare identifier; static attributes take plain text. Nested instances no longer lose a boolean `v-if` guard, instance swaps render through `<component :is>`, slot names stay distinct when their idents lowercase alike, slot content CSS reaches the parent stylesheet, and text defaults escape `</script>` in the script block. The five locale catalogs the Vue key rename missed (bn, fi, ka, nl, pl) are updated, and the docs list Leptos/Vue consistently.
2026-10-22 19:52:58 +08:00
Nicek 37921f48ce feat(inspect): 为资产列表添加格式选择下拉菜单和预览缩略图 2026-10-22 19:51:25 +08:00
MortenHusted f1c206509f M3: files from before the modes override keep the modes set inside their instances
A file or library saved before "modes" was an override group has a mode
chosen on a layer inside an instance without its marker, so the next
rebuild or library import still put the main's mode back.

Copies follow their main layer's modes when the main changes them, so a
layer inside an instance on other modes chose them itself.
mark_layer_modes marks those when a file opens and when a published
library is read from a file or a server. Layers on their main's modes
and top-level instances stay unmarked.
2026-10-22 13:41:56 +02:00
MortenHusted 6394e96005 M3: a variable mode set on a layer inside an instance is an override
A variable mode chosen on a layer inside an instance (the icon inside a
Close button, recoloured through an icon colour collection) wasn't an
override group. A full rebuild of the instance starts each layer over
from its main and carries only override groups, so the mode fell back
to the main's. Library import always rebuilds, as do main edits such as
a new auto layout direction, so a modal's recoloured close icon came
into other files in its default colour.

"modes" is now an override group: setting a mode inside an instance
records it, rebuilds carry it, rebasing a nested instance finds it, the
component panel lists it, and resetting it brings back the main's mode.
A top-level instance's own modes stay its own and record no override,
like its resizing.
2026-10-22 13:41:56 +02:00
Dylan M. Taylor 9ec80bf2c2 Say the library computes month and day; time-of-day is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 07:12:29 -04:00
Dylan M. Taylor a0e937918c Name the 400-year range extension as the one exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
2026-10-22 07:12:29 -04:00
Dylan M. Taylor e3169e5a61 collab/ui-egui: keep old UTC output past chrono's year range
`utc_date` (collab) and `date` (ui-egui) took `u64` seconds, which the old
hand-rolled civil-from-days code converted for the whole `u64` range. The
chrono rewrite used `from_timestamp(..).unwrap_or_default()`, so any instant
past chrono's own year limit (262143) silently became the epoch — changing
existing output. `u64::MAX` seconds, for example, used to read
`584554051223-11-09 07:00 UTC` / `Nov 9, 584554051223`, not 1970.

Fix: add a `utc_parts(days, sod, nanos)` helper per crate that moves the
instant into chrono's range by whole 400-year Gregorian cycles (146_097 days),
lets chrono do the month/day/time, and restores the real year afterwards.
chrono stays the only calendar logic; the cycle count just shifts the year, and
every `u64` day count fits in `i64` so the conversion is always exact (no epoch
fallback). Split seconds with `u64` arithmetic first to avoid the `i64` seconds
overflow near `u64::MAX`, matching the old code.

Tests now pin the old output instead of the epoch:
- collab utc_date(u64::MAX) == "584554051223-11-09 07:00 UTC"
  (and utc_date(253_402_300_800) == "10000-01-01 00:00 UTC", already pinned)
- ui-egui date(253_402_300_800) == "Jan 1, 10000"
  date(u64::MAX) == "Nov 9, 584554051223"
2026-10-22 07:12:29 -04:00
Dylan M. Taylor 630e0dd403 Keep 5-digit years unprefixed in utc_date
chrono's %Y writes a '+' before years past 9999, whereas the old hand-rolled
{y:04} did not. utc_date now writes the year with {:04} to keep the exact old
output. from_timestamp already reads a clock past chrono's range as the epoch
rather than panicking. Tests cover the 5-digit-year and out-of-range cases.
2026-10-22 07:12:29 -04:00
Dylan M. Taylor 0e3876e6d6 Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the two hand-rolled Howard Hinnant days-from-civil copies with chrono
as a workspace dependency:
- crates/collab/src/message.rs: utc_date (YYYY-MM-DD HH:MM UTC)
- crates/ui-egui/src/home_recents.rs: date (Mon D, YYYY, UTC; keeps the
  translated month names and trf formatting)
Output is unchanged. Neither function had a test, so each gains one pinning the
exact strings the old code produced (leap days included). chrono uses only std,
so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.

Follow-up to storytold/cadcraft#1785
2026-10-22 07:12:29 -04:00
Nicek 785dc437fa fix(vectoredit): 编辑模式结束时移除无任何线段的空图层
新增空图层检测函数,在结束矢量编辑模式时自动删除没有任何线段的图层,避免留下空图层或空框。同时更新相关测试用例和文档,确保该行为符合预期。
2026-10-22 19:11:39 +08:00
BFlat 5fa586306a Merge pull request #1049 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:42 +07:00
bflatastic ce8a224c9e README: the app icon above the title 2026-10-22 17:57:45 +07:00
AganzeFelicite 73a8bb83d6 M7: test Type settings' width through the whole app in Georgian and French (#1029) 2026-10-22 12:24:24 +02:00
MortenHusted c141dee771 M3: a swapped nested instance keeps its outer main's overrides
A Button main's override on its icon instance's glyph (a red fill) was
lost when an instance swapped the icon: the outer rebuild re-copies the
glyph from the main's layer with no overrides of its own, so the nested
rebuild matched it by name and carried nothing. Before that rebuild, each
old layer of the swapped instance now takes the overrides held along the
source chain it had before this rebuild, through layers inside instances
in the mains that place the swapped instance. Geometry, slot content and
groups the layer overrides itself are left out. Matched by name, the
values carry onto the new main's layer as the instance's own: inherited
at the swap, frozen after, and back to the icon main's value on reset.
An icon main's own overrides stay with it.
2026-10-22 11:26:04 +02:00
MortenHusted 6db58d32cb M4: individual stroke weights follow the corner radius like CSS borders
stroke_area used rect_side_strokes only for square corners; a rounded
rect-like layer with individual weights fell through to a uniform ring at
stroke.weight (canvas, SVG, PDF, shadows, shape builder, outline stroke).
pathops::rounded_side_strokes now draws them as CSS does: the outer edge is
the rounded rect (the fill outline for inside strokes), the inner edge is
inset by each side's weight with elliptical corners max(0, R - weight), so
border-l-4 rounded-r-lg is a straight bar and a weight that differs across
a rounded corner tapers through it. Center and outside grow the outer box
and each non-zero radius by half or all of the weight (CSS has only
inside). The inner edge runs against the outer one, so the ring is a ring
under non-zero too; where it could leave the outer edge (radii shrunk to
fit a narrow inner box, smoothed outer corners) it is cut out instead.
2026-10-22 11:20:24 +02:00
NetterSilverandClaude Opus 5.5 93c0bb9ce2 Keep not <var> from reading back as a variable named "not …"
Found while testing crates/doc/src/proto_expr.rs:172 (display writes `not {x}` with a bare var_ref) with crates/doc/src/proto_expr_parse.rs:262 (not() checks variable names before the `not` keyword)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:20:19 +00:00
MortenHusted 24a5855cab M3: style links given in a call apply after its values
apply_order gave fillStyle, strokeStyle, effectStyle, gridStyle and
textStyle the rank of plain values, so the JSON map's byte order decided:
fills, strokes, effects, grids, layoutGrids and variationSettings sort
after their link and overrode it (node.set then unlinked the style;
node.create kept a link its values contradicted). Style links now apply
after the values and x/y, just before boundVariables. A deviation set in a
later call still unlinks.
2026-10-22 11:11:59 +02:00
NetterSilverandClaude Opus 5.5 79e722d340 Keep literal true/false distinct from variables named true/false
Found while testing crates/doc/src/proto_expr_parse.rs:462 (variable lookup before the true/false literals at :466) with crates/doc/src/proto_expr.rs:151/186 (display writes the literal as bare `true` and var_ref writes such a variable bare)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:56:12 +00:00
MortenHusted e8c4742874 M6: the open-subpath fixture covers stroke-first paint order
A third row in open_subpaths.svg: a filled and stroked path that is only
0.001 short stays one layer, and an open triangle with
paint-order="stroke" puts its stroke layer under its fill layer.
2026-10-22 10:27:34 +02:00
RealtaNuaandClaude Opus 5.5 85010e491a M7: missing fonts: follow each font variable's aliases once (#752)
A file whose font family variables alias each other in several modes made
the missing-fonts walk branch in every mode, billions of steps before the
depth limit stopped it, freezing the app on open and `figcraft-cli info`.
The walk now visits each variable once.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 15:19:11 +07:00
MortenHusted a713897d87 M6: SVG import fills open subpaths the way SVG does
SVG fills every subpath as if it were closed; the import filled only
subpaths that close themselves (a z, or an end within 1e-6 of the
start). Logos exported from PDF round relative coordinates to three
decimals, so their outlines end about 0.001 short of where they started
and most of their letters came in empty.

A filled path now has its open subpaths closed before it becomes a
vector network: an end within 1/1000 of the path's size snaps onto the
start, any other open subpath of two or more segments gets a closing
segment. Stroke-only paths are unchanged. A filled and stroked path
with a really open subpath becomes a fill layer (closed) under a stroke
layer (open), so the stroke stays open as SVG draws it.
2026-10-22 09:54:27 +02:00
MortenHusted 0449edf645 M8: save-format tests keep their temp folders apart
SaveFiles named each test's folder after the process id and the
clock in nanoseconds. The tests run in parallel and the macOS clock
counts microseconds, so two of them often got the same folder and one
failed with AlreadyExists (14 of 30 runs here). A counter now keeps
the names apart.
2026-10-22 09:37:35 +02:00
NetterSilverandClaude Haiku 5.5 857762e65c Treat U+2007 FIGURE SPACE as non-breaking in line breaking
Found while testing crates/text/src/breaks.rs:42

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 07:36:21 +00:00
MortenHusted 8ee0218d19 M3: a variant switch takes the new variant's resizing unless the instance set its own
Switching an instance to another variant copied the old root's sizing
and min/max back over the new variant's on the rebuild, so an instance
switched from a hugging variant to a fixed-width one whose children
fill kept hugging at the old width. The next edit then turned those
children Fixed, as a child can't fill an axis its parent hugs.

Resizing still equal to the old variant's was never the instance's own,
so on a sibling-variant switch it now follows the new variant; sizing or
min/max the instance set itself stays.
2026-10-22 09:33:12 +02:00
AganzeFelicite e6067549bc M7: Type settings keeps its width with long labels (#1029) 2026-10-22 09:21:24 +02:00
Muamer MustaficandCursor 4b71d2c69e M1: individual corner fields sit at their corners, each with its own icon (#1002)
Co-authored-by: Cursor <[email protected]>
2026-10-22 09:13:19 +02:00
Jindřich ŠaršonandClaude Opus 5.5 7cb4168e13 M6: Sketch import: fixes from the PR review
- --no-system-fonts holds for .sketch imports too: the CLI opts the
  process out, so the import's font matching doesn't scan.
- Shared text styles get the installed family and platform tracking
  their linked layers get.
- Each text run writes its letter spacing, line height and font axes,
  neutral when it has none, instead of taking the first run's.
- The unsupported-drop hint and its catalogs list .sketch.
- A test sends damaged .sketch data through file.open and file.import.
- Stack items are matched to their source layers through a map.
- Docs: .sketch is a convert input; the Carbon kit's Sketch 47 edition
  is refused (the 68 and 83 ones open).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:03:17 +02:00
AganzeFelicite b13e5c4ff3 M7: doc comments on the font scan's folders and scan_dir (#298) 2026-10-22 09:00:37 +02:00
phonedu 7c211d8b0d Review: escape Vue binding attributes and use the template's indent
- usage: a non-plain Vue argument escapes " as &quot; in the attribute value (same rule as the connected-code path)
- usage: Vue pads with two spaces like the markup, not four
- test: an unconnected instance with a quoted title asserts inspect.componentCode and inspect.code
2026-10-22 14:46:07 +08:00
Nicek a7e4096ba3 Merge remote-tracking branch 'origin/main' into local/vector-edit 2026-10-22 14:16:37 +08:00
phonedu 8787f46205 M6: a Vue code target (single-file components)
Dev Mode generates React, Leptos, SwiftUI and Compose; a Vue project had to translate the HTML
instead. `inspect.code {language: "vue"}` now writes a single-file component: a `<template>`, a
`<script setup>` with `defineProps` and its defaults, and a `<style scoped>`.

A Vue template holds no arbitrary expressions, so what the web targets write as a braced
expression has its own spelling. Text is `{{ prop }}`, and braces in text are escaped; a boolean
property's layer carries `v-if="prop"` rather than a wrapper element; a slot becomes Vue's own
`<slot name="…">` with its fallback content, which takes no prop, and an instance's content in one
is a `<template #name>`. Variants switch on the root's `data-*` attributes, as on the web.

`codeConnect` links a component to its code as a single-file component: a default import, and a
quoted binding, so `:size={size}` gives `:size="Large"` and a component swap passes the component
itself. A value that needs escapes keeps them inside the quotes, where the attribute's own quotes
become `&quot;`.

`inspect.componentCode` kept a second copy of the code-language list, which is how Vue was missed
when `inspect.code` learned it; it now asks `code_connect::language`, the one list, as
`codeConnect` does.

Eleven tests in `vue_codegen_tests` and a `codeConnect` case; the Inspect panel's language picker
picks Vue up from `CodeLang::ALL`, and the language lists in the docs, the Dev Mode hint and its
thirteen catalogs name it.
2026-10-22 14:12:45 +08:00
Nicek 90004dc7cd fix: 修复多选矢量点时变换框的交互逻辑
修复了多选矢量点时,变换框角点会误判为可拖动的点的问题,同时更新了相关注释和测试用例,确保未被点占据的角点和边缘仍可用于缩放操作。
2026-10-22 13:52:06 +08:00
Muamer Mustafic aa5cc840e5 M1: re-run CI (#910) 2026-10-22 07:46:23 +02:00
Tống Huy Sơn 80b9b49109 docs(i18n): reconcile catalog entry counts to 2,352 in localization-parity.md 2026-10-22 12:02:08 +07:00
Nicek d3a1538614 fix: 修复矢量编辑模式下点击空画布的行为逻辑 2026-10-22 13:00:27 +08:00
Tống Huy Sơn e3bf9b7270 i18n: add Vietnamese (vi) interface localization 2026-10-22 11:33:34 +07:00
Nicek 6bb1f3a456 feat: 实现矢量编辑模式下的剪切命令逻辑并添加测试
在矢量编辑模式中,剪切命令会处理选中的顶点并自动修复路径,而非直接删除整个图层。新增了专门的cut函数处理该逻辑,同时添加了vector_keys_tests测试文件,覆盖了不同场景下的剪切行为,包括Windows系统的Shift+Delete映射、选中顶点时的剪切、无选中项时的剪切以及非编辑模式下的剪切。
2026-10-22 12:03:19 +08:00
Nicek fa53463ddd feat: 为矢量编辑添加线段中点添加点的交互和提示 2026-10-22 11:01:42 +08:00
vxMacDev 3d41302f23 fix(engine): show the Pen's Shift 45° step in its preview line (#970)
A Shift-click with the Pen already put the next point at a 45° step
from the last one, but the line drawn to the pointer while moving
ignored Shift and followed the pointer, so holding Shift looked like it
did nothing. The line now ends where the click will put the point. Both
use one helper, at_45_degrees.
2026-10-21 19:47:01 -05:00
Dylan M. Taylor a86897dc91 ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#1015)
Pin third-party actions to full-length commit SHAs (with version comments)
instead of mutable tags/branches to prevent supply-chain tampering via
retagged releases. Resolves SonarCloud rule githubactions:S7637.
2026-10-21 19:36:23 -04:00
Dylan M. Taylor cb7966556c security: enforce HTTPS on curl downloads (SonarCloud S6506) (#1016)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 19:36:11 -04:00
Jindřich ŠaršonandClaude Opus 5.5 03c5324305 M6: Sketch import: count it in the parity assessment
Figma imports Sketch files from its file browser (desktop app too); the
area table hadn't listed it. Import/export breadth 86% → 87%, file
formats 60% → 61%; headline numbers unchanged after rounding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 054d53900f M6: Sketch import: note embedded fonts as waiting for .figcraft font support
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 3e92904cba M6: Sketch import: review fixes
Texts an auto layout places keep the layout's position (the next layout
pass would take the baseline move back). A faded layer's group takes the
layer's id, so stacks, links and overrides still find it; faded masks
stay masks. figcraft-format no longer depends on figcraft-layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 40c7cb3029 M6: Sketch import: tint copies and handed overrides count as work
Tinting copies a group's or master's layers; the copy is charged to the
import's work budget (past it, tints are skipped with a note). An
override handed to a nested instance costs its value's size, so deep
chains of large overrides can't be copied without end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 79894cfad2 M6: Sketch import: junk tints and colour overrides can't panic
A tint or fill that isn't a JSON object is left alone instead of being
indexed. A structural fuzz replaces each value of a rich file with values
of other types and imports every variant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 5f5b802127 M6: Sketch import: roadmap and parity rows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 de0650e3f7 M6: Sketch import: layout grids come in hidden
Sketch draws an artboard's layout and square grids only while its layout
view is on; they now import hidden (still there, shown with the eye in
the Layout grid section).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 a8bade1446 M6: Sketch import: alpha masks, Fade, linear gradient geometry
Alpha masks only mask (no drawn copy of their fill); a progressive
opacity (Fade) becomes an alpha mask of its gradient over the layer;
linear gradients run square in pixels as Sketch draws them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 f02557e8f9 M6: Sketch import: fonts by PostScript name, Sketch's natural line
The font database knows each face's PostScript name; the Sketch import
finds the installed face a file's font names (CourierNewPSMT is Courier
New Regular) and reads the family from the name only when it isn't
installed. Text without a fixed line height takes the line Sketch draws:
ascent and descent rounded to whole pixels, the line gap left out, a
fifth more for classic Mac fonts, the baseline where Sketch puts it
(measured on sketchtool renders of 80 fonts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 41ff790d77 M6: Sketch import: variable font axes; no false embedded-font note
Sketch keys a font's variation axes by the tag read as a number; they map
onto the text style's variations (a fontVariations key in the REST
mapping). Font references without font data only name the fonts a
document uses and no longer raise the embedded-font note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 c639cc2b76 M6: Sketch import: Sketch 2025 frames, stacks, tints and corners
Groups as frames and graphics, stacks as auto layout (instances laid out
by their stack), corners and per-side borders from the style, pins and
sizing, tints (layeringType, fillColor overrides), text colour and
visibility overrides, blur lists (progressive, glass), inner shadows in
the shadow list, angular gradients starting on the right, text sizing,
and texts an override changed measured again on open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 ad78ee94c3 M6: Sketch import: instances, artboards and Apple fonts like Sketch
Compared with Sketch's renders of a Sketch 64 file. Resized instances lay
out their layers the way Sketch does (measured: pins share the change in
proportion, a fixed size keeps its centre's share, and files before format
110 just scale); masters' backgrounds show in instances only when Sketch
includes them; swaps to a missing symbol keep the original; artboards
don't draw a style they carry. Apple's system fonts are found under the
names designs use (SF Pro, SF UI Text and Display, SF Compact), the
optical size follows the text size for named styles too, and imported
text keeps the platform's trak tracking as letter spacing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 5b9e18317d M6: Sketch import: match Sketch's own rendering
Compared artboard by artboard with Sketch's renders of a real Sketch 50
file (sketchtool, black-box). Shape groups whose children have no
operation are one compound path filled by the group's winding rule (read
from the layer), not a union: rings, map streets and icons keep their
holes. Filled open paths close, as Sketch fills them. Flips apply after
rotation. Text with a fixed line height sits where Sketch's line spacing
behaviour puts it. Edited ovals and rectangles keep their points. Bitmap
colour adjustments become image filters. scale 0 means unscaled. A local
test compares sample files with sketchtool's renders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 6640498195 M6: Sketch import: hostile files can't panic, run out of memory or hang
From the branch review: overrides that point at junk (shared styles that
aren't objects, text runs that aren't) no longer panic; symbol expansion
spends a work budget (JSON copied, overrides handed on to nested
instances, grouped once by their first path segment), so small files
can't expand into gigabytes; far path points stay in range; notes are
capped and de-duplicated with a set; shared styles are looked up by
index; a styled second mask's drawn copy isn't clipped by the first;
JSON members are refused past 100 times their packed size or 2 GB in
all, and an unreadable document.json keeps the pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 233c8e9708 M6: Sketch import: local corpus check and docs
FIGCRAFT_SKETCH_SAMPLES runs every .sketch file of a folder through
file.open (opens with linked instances and finite geometry, or is
refused as too old). File format parity gains a .sketch row; CLI, MCP,
roadmap and ROADMAP mention the import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 31698164ac M6: Sketch import: prototype links, export settings, layout grids
Layers linked to artboards navigate there on click (back links go back,
slide animations slide), hotspots become transparent frames with their
link, flow-home artboards start a flow; export options become export
settings (scale, width or height, suffix); artboard layout and square
grids become layout grids, which instances show like their masters.
Smart Layout, stacks and locked overrides are noted once per file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 4be7017ed1 M6: Sketch import: shared styles and colour variables
Shared layer styles become a paint style for their fills, a paint style
for their border colours (linked to strokes) and an effect style; shared
text styles become text styles (colour stays on the layers); local and
library copies alike, unused ones listed too. Layers link to them, keeping
their own values where they differ. Color Variables (swatches, also
library ones) become colour variables in a Color Variables collection,
with fills, borders and text colours bound to them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 4437d49a72 M6: Sketch import: symbols as components, instances with overrides
Symbol masters become components, also those copied from libraries (on a
page of their own); instances carry their master's layers with their
overrides applied (text, nested symbol swaps and hiding, images, layer
and text styles, nested paths) and resize them by their constraints, so
editing a master still updates every instance. A symbol inside itself
stops with a note; instances of missing symbols are empty frames.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 60cc184ab7 M6: Sketch import: PingFang families keep their own names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 a0542e581c M6: Sketch import: text with styled runs and font names
Text layers keep their string and styled runs (font, size, colour, line
height, letter spacing, decorations, text transform; ranges in UTF-16
units, clamped), paragraph alignment and spacing, vertical alignment and
how the box sizes. Fonts named by PostScript name become family and
style; the engine then matches families to installed ones ignoring
spaces (PingFangSC → PingFang SC). Fonts embedded in newer files are
named in the import report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 ff9d966ea6 M6: Sketch import: masks that show their own style draw it
Sketch draws a clipping mask with its fills, borders and effects under
what it clips; a FigCraft mask only clips. A plain copy of such a mask
goes underneath it, so a light avatar circle with a white icon in it no
longer vanishes on a white artboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 8f32e4f504 M6: Sketch import: shape groups that are clipping masks clip
Sketch 51 and older files make masks from shape groups; the mask flag was
lost when the group became its shape or boolean operation, so a masked map
spilled out of its card.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 d77d57912b M6: Sketch import: fills, borders, effects, opacity, masks
Solid, gradient (linear, radial with its ellipse, angular) and image
pattern fills (tile, fill, stretch, fit); borders as strokes with the
first enabled border's width and position, dashes, caps, joins and arrow
ends; shadows, inner shadows, layer and background blurs; layer and paint
opacity and blend modes; clipping masks, wrapped in a group of their own
where Sketch breaks the mask chain; Display P3 documents. Shape groups
pass their style to the shape or boolean operation they become. Noise
fills, motion and zoom blurs are listed in the import report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 72c7eae868 M6: Sketch import: editable paths, shape groups, booleans, stars, polygons
Paths become editable vector networks (handles, per-point corner radius,
open and closed, winding rule). A shape group with one shape becomes that
shape in the group's place; several combine as boolean operations, mixed
operations nesting from the left as Sketch applies them. Stars and
polygons stay stars and polygons while their points are the generated
ones, edited ones and triangles become vectors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 5805cea9b4 M6: Sketch import: layer tree, transforms, constraints, basic shapes
Artboards (and Sketch 2025 frames) become frames with their background
(white by default, as Sketch shows them), groups and graphics groups,
rectangles with their corner radii, ovals ellipses, bitmaps rectangles
with the image, slices slices. Positions accumulate through groups,
rotation and flips keep their direction, resizingConstraint bits become
constraints. Not yet mapped layer kinds are listed in the import report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 a30f846ee3 M6: open Sketch files (48+): detection, pages, version cut, wiring
.sketch archives (document.json, meta.json, pages/*.json) open through
file.open, file.import, drag and drop, the CLI and MCP; files saved by a
Sketch older than 48 are refused with a message saying how to convert
them. Layers come in the next commits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Muamer MustaficandCursor 84b48bc797 M1: the drag-from-inside fallback tests the selection's turned box (#910)
Co-authored-by: Cursor <[email protected]>
2026-10-22 00:19:13 +02:00
F3R9 6c0ffe6677 M9: make graphics-startup assertions independent of UI language 2026-10-22 00:42:48 +03:00
Muamer MustaficandCursor 795567f016 M1: a selected unfilled shape drags from inside its box (#910)
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:37:09 +02:00
Ridzzz0Alam 82b05803b1 M1: a refused browser download keeps the unsaved changes (#969)
`file.saveAs {data: true}` marked the file saved before the UI handed the
bytes to `Services::download`. When the download was refused, the toast
said "Couldn't download" but the document was clean, so closing it no
longer warned and the edits could be lost.

The data result now leaves the save state alone. A new `file.markSaved`
(the bookkeeping a native save already did: the file and its branch tabs)
runs from `save_as_dialog` only once the download was delivered. It ends a
version preview like the other commands that act on the document itself.

Tests: the engine save test checks the file stays dirty until
`file.markSaved`; `a_refused_download_says_so` checks the document is
still dirty.
2026-10-21 22:48:51 +02:00
YarilaFox 5ab748d207 M7: keep interface size menu focus inside popup 2026-10-22 01:27:13 +05:00
Dylan M. Taylor c1a4f2d954 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:36 -04:00
vetlucasmartins 9f2470b5bd Tailwind theme names: resolve collisions in linear time
Clash detection compared every variable with every other, and suffixes
rescanned the taken list, so a file with very many variables made the
export quadratic. Counts in a HashMap, taken names in a HashSet and a next
suffix per base keep it linear.
2026-10-21 21:00:02 +01:00
Dylan M. Taylor 0ab000b74b ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:57 -04:00
Ridzzz0Alam f0e75e183b M1: swap defaults past the layer cap say so (#981)
Instance swap defaults that name an in-file main skipped by the layer cap
were reported as "isn't in the file": only instance targets fed the
capped-mains list. Defaults now feed it too.
2026-10-21 21:06:59 +02:00
vetlucasmartins 2118480e7a Dev Mode: React + Tailwind page code and Tailwind theme export
- inspect.code {language: "tailwind"}: a layer tree as a React component
  styled with Tailwind CSS v4 utilities, no stylesheet. Auto layout maps to
  flex utilities on the spacing scale; fixed-width auto layout frames, text
  and wide layers become fluid (w-full max-w-[…]); instances are inlined
  with their overrides, except those connected to code (codeConnect), which
  are their snippet with one import each; bound variables are theme
  utilities (bg-accent, rounded-card).
- mobile: <frame id> (same layer names): mobile-first code, desktop classes
  that differ behind lg:, resets for desktop defaults, padding compared per
  side; layers only on desktop are hidden below lg, those only on mobile are
  listed in the header.
- variable.export {format: "tailwind"}: @import "tailwindcss" and an @theme
  block (--color-*, --radius-*, --text-*, --spacing-*, --font-*), names
  unique per file and shared with page code.

Builds on the token roles of the CSS export px fix (#1008).
2026-10-21 20:03:46 +01:00
vetlucasmartins 196e3432dd data.fill and text.fit: fill copies of a frame from rows of data
- data.fill {id, rows, gap?, formats?, dir?, format?, scale?}: repeats a
  frame once per row in one undo step; each row key names layers in the
  copy (text, or {text?, fill?, visible?, fit?}). `formats` makes each row
  once per size (feed, story, square), the copy resized so its auto layout
  reflows. With `dir`, each copy is exported under a unique file name; a
  failed export is reported in `errors` and keeps the copies.
- text.fit {ids?, maxLines?, minSize?, orphans?}: the largest font size,
  down to a floor, at which fixed-width text stays within N lines and its
  box; avoids a one-word last line; `fits` comes from the final text.
- Plugins can't call data.fill: it runs other commands inside, past the
  plugin policy, like command.batch.
2026-10-21 19:54:47 +01:00
vetlucasmartins 40fd169bec variable.export css: number lengths in px
A radius exported as `--radius: 12` was invalid where CSS reads it as a
length (`border-radius: var(--radius)` ignores a bare number). Number
values now carry px unless the layers bound to them, their scopes, or the
variables aliasing them make them an opacity or a font weight, which stay
bare. The roles come from a small token_roles module.

Updates the variables_io_tests expectation for the radius (4 → 4px,
6.5 → 6.5px), and adds a test for a bound radius, a bound opacity and an
opacity alias's target.
2026-10-21 19:38:30 +01:00
Ridzzz0Alam 09ff20ffd6 M1: several exported layers arrive as one zip in the browser (#969)
Cause: in the browser, exporting several layers started one download
per file. Chrome holds back every download after the first until the
site is allowed automatic downloads, so the other files didn't arrive.

Fix: file.exportLayers takes `zip: true` (without `dir`) and returns the
files together as one base64 zip `data`, written by a new
figcraft_format::zip_files (the workspace's existing zip dependency).
The UI asks for it when the platform saves by downloads and hands over
one `<document name>.zip`.

Test: ui-egui several_layers_download_as_one_zip (one download, a zip
holding both layers). It fails on the previous commit (two downloads).
2026-10-21 20:35:13 +02:00
KameronandClaude Opus 5.5 014098b6c9 fix(components): a boolean property's default is its bound layers' visibility on the main (#993)
component.addProperty {type: "boolean", default: false, layer} hid the
bound layer in new instances but left it showing in the main component,
so the main didn't show its own default.

On the main, a boolean property's value is now the visibility of the
layers bound to it:

- addProperty, bindProperty {field: "visible"} and editProperty {default}
  show or hide the main's bound layers to match the default (every
  variant of a set for editProperty). Without a default, addProperty
  takes the bound layer's current visibility instead of always `true`.
- Showing or hiding a bound layer on the main sets the default, and the
  property's other bound layers (in every variant) follow. Edits inside
  instances stay overrides.
- Existing instances keep their stored values; documents open as saved
  (no normalisation on load).

Fixes #993

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:27:46 -07:00
KameronandClaude Opus 5.5 afdc31b727 Let node.set replace a vector's path
node.set took no `path`, so redrawing a vector meant deleting and recreating it and
losing its id, place in the layer list, paints, styles, bindings and step references.

`path` now takes SVG path data for vector layers and replaces the geometry through
the same reader node.create uses (path_network, shared with create). width/height in
the same call scale it as create does. The path's own box goes at the layer's current
origin, so rotation and flips stay; with x/y in the same call it goes at x/y plus the
box's offset, which is where create puts it.

On several layers, layers that aren't vectors are skipped with a warning, as auto
layout keys already are (the skip logic is shared now). A single non-vector is
refused, and so is a vector inside an instance: there is no geometry override, so
the next sync would undo it. Data that doesn't parse, draws nothing or has
non-finite bounds is refused cleanly; create now refuses non-finite bounds too.

Tests cover keeping the layer, placement and scaling against create, rotation,
undo/redo, invalid data, non-vectors, instances, boolean operands, edit mode, and a
junk-path sweep. The engine-wide junk sweep has node.set path entries.

Fixes #1001

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:21:24 -07:00
KameronandClaude Opus 5.5 9315e5e014 command.batch: named steps ($ref) and slash paths in field
A batch step can name itself with "as" and later steps refer to it with
{"$ref": "name"}, alongside positional {"$step": n}. "field" now takes a
slash path into the result as well as a key, so {"$ref": "copy",
"field": "ids/0"} reaches the first id edit.duplicate returned (the
convention variable.bind uses for fills/0/color; a leading "/" as in
JSON Pointer also works, and a key holding "/" still matches first).

Resolution moves to crates/engine/src/batch.rs (BatchStep, BatchScope),
shared by command.batch, the CLI script runner, the control channel and
the MCP batch tool, which now passes "as" through. Duplicate names fail
before any step runs; unknown, same-step or later names, bad paths and
out-of-range indexes fail the batch with the step, name and full path.
$ref is reserved like $step; nested batches keep their own names.

Fixes #994.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:19:47 -07:00
KameronandClaude Opus 5.5 3146ad2b2f node.reparent: index is the layer's final position (#992)
`node.reparent {ids, parent, index}` read `index` against the parent's
children as they were before the move, so within the same parent moving a
layer up one place did nothing and asking for two places moved it one.
`index` now counts the parent's children after the move, the way
`node.reorder` already does, in the same parent or a new one; several
layers still land together in their stacking order.

The layers panel was the only caller passing an index other than 0. It
drops on a gap between rows counted before the move, so it now subtracts
the dragged layers below that gap and drags behave as before.

Documents `index` in the command's params and in docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:03:08 -07:00
Brandon ThomasandClaude Opus 5.5 28de91d9ae Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:56:51 -04:00
Ridzzz0Alam 5e2957a901 M1: Save, Save as and Export arrive as downloads in the browser (#969)
Cause: in the web build no file has a path, so Save goes to Save as, and
Save as and every Export go through Services::pick_save / pick_folder.
WebServices keeps the trait's defaults there, which return no path, so
the callback never ran: no download, no message. Only Variables ▸ Export
used the platform's downloads (#368).

Fix: where services.downloads() is true, those routes run their command
without a path and hand the bytes to services.download():
- engine: file.saveAs takes `data: true` and returns the .figcraft file
  as base64 `data` with its `name`, marking it saved (no path, nothing
  remembered). Programmatic Save as without either still asks for a path.
- ui: FigApp::download decodes a result's `data` and says when nothing
  arrived; Save as, one-off export (PNG/JPG/WebP/SVG/PDF), .fig export,
  frames to PDF and multi-layer export (one download per file) use it.

Tests: engine save_as_data_hands_back_the_file_and_marks_it_saved;
ui-egui web_save_tests (save, export, refused download) with the fake
browser Services from variables_export_ui_tests. The UI tests fail
without the fix.
2026-10-21 19:15:58 +02:00
midasdf 42bdfcce34 M4: CJK kinsoku completed to JIS X 4051
JIS X 4051's line-start (行頭禁則) and line-end (行末禁則) sets were mostly
there — closing punctuation and brackets, small kana, the prolonged sound
mark, iteration marks and opening brackets already never start or end a line —
but a few categories fell through:

- the halfwidth small kana ァ–ッ (U+FF67..U+FF6F) and the halfwidth voicing
  marks ゙゚ (U+FF9E/U+FF9F) were not line-start prohibited, so a wrapped line
  could begin with them;
- the spacing (semi-)voiced sound marks ゛゜ (U+309B/U+309C) and the kana
  iteration marks 〱〲〳〴〵 (U+3031..U+3035) likewise;
- the halfwidth parentheses ⦅⦆ were missing from the line-end/line-start
  sets.

Add them (ranges in `class`, the two parentheses in the OPEN and CLOSE
strings), and pin every JIS category in the behaviour suite: the existing 22
cases stay and 9 join them (halfwidth small kana and small tsu, halfwidth and
spacing voicing marks, kana and vertical kana iteration marks, both halfwidth
parentheses), plus a wrap case where a halfwidth small kana moves down with
its base (キャ) and a case showing a manual newline may still start a line with
a prohibited character (禁則 is for automatic wrapping only).
`break_opportunities` fails on the old sets at the first new halfwidth
small-kana case.

Latin wrapping, ideograph breaks and the rest of the behaviour suite are
unchanged, and the headline numbers stay as they are.

Verified: cargo test -p figcraft-text --lib 92 passed; cargo clippy
-p figcraft-text --all-targets -- -D warnings clean; rustfmt clean on the
touched files.

Docs: docs/target-app-parity.md text behaviour row and ROADMAP progress log,
revision history and header.
2026-10-22 01:37:55 +09:00
Ridzzz0Alam e211bf5476 M1: large .fig files keep their layers and their library links (#981)
A design-system .fig file (54 pages) stopped at 244 248 layers, the rest
of its pages skipped, and most of its instances came in as frames "of
components that aren't in the file", with the cap note shown twice.

- The layer budget follows the compressed canvas at 8 bytes a layer, not
  32: design systems are mostly instances, which pack tightly. Files under
  about 1.6 MB keep the 200 000 floor and the 2 million ceiling stays
  (measured on synthetic files: about 5.4 KB and 19 µs a layer).
- The library copies the pages use (the file's hidden internal canvas) are
  mapped before the pages: at the cap the pages' last layers are skipped,
  not the mains their instances link to. The REST stage keeps building
  pages first (its ids are first-wins) and allows for the wrapper frame of
  each library copy, the one layer the mapper doesn't count; that layer
  tripped the REST stage's own cap and repeated the note.
- Instances and swap defaults whose main is in the file but past the cap
  say so instead of "aren't in the file" (en and es catalogs).
- The .fig import gives a note the mapper already gave only once.

Tests in fig/layer_cap_tests.rs: the budget for a #981-sized canvas, library
mains ahead of the pages at the cap, the note for a capped local main, and
a whole 210 000-layer file with one cap note and its library links kept.
All four fail on the previous code.
2026-10-21 18:22:16 +02:00
Nicek 2a3c5b4689 feat: 改进双击行为,编辑形状点时更符合平台习惯 2026-10-22 00:16:44 +08:00
Ridzzz0Alam f619dca716 M1: = zooms in as - zooms out (#973)
Plain `-` zoomed out but plain `=` (the `+` key without Shift) did nothing,
so a canvas zoomed out with `-` couldn't be zoomed back in the same way;
held down, `-` reaches the 2% floor and `=` leaves you there. Ctrl+Shift+=
zoomed in while Ctrl+Shift+- did nothing either.

Found with a trace of the input on GNOME Wayland: Ctrl+wheel, Ctrl+=,
Ctrl+-, Ctrl+Shift+= and `-` all reach the canvas and zoom; `=` and
Ctrl+Shift+- arrive too but no binding takes them.

`view.zoomIn` gains `=` and `view.zoomOut` gains `Shift+Cmd+-`, pinned in
the shortcut parity table; neither key was bound elsewhere and the menus
still show Ctrl/Cmd and `+`/`-`. A keymap test checks each zoom-in key
has its zoom-out twin on both platforms.
2026-10-21 17:41:30 +02:00
Ridzzz0Alam 12bb6503bd M1: readable dropdown rows in the light theme (#972, #955)
Panel dropdowns (prototype device, Preferences, blend modes, versions…)
draw their rows with `right::menu_row` on the theme's popup fill, which is
white in the light theme. Since 5a4b7c27 those rows painted their labels in
`menu_text`, white in both themes because it belongs to the dark menu bar,
so the options were invisible until hovered.

Rows now take the theme's text (`t.text`, shortcuts `t.text_secondary`) and
white only on the green hover highlight, as before that commit. The menu
bar, tool flyout and style/variable pickers paint their own rows on
`t.menu` and are unchanged.

Regression test: a dropdown opened headless in both themes keeps its labels
at 4.5:1 and shortcuts at 3:1 against the popup fill.
2026-10-21 17:00:21 +02:00
Nicek 12e0cdc147 feat: 为填充、描边和效果添加右键复制粘贴功能
实现了在属性行和分区标题上右键菜单,支持复制粘贴单个填充、描边和效果。新增了相关的命令、测试和国际化文本,完善了剪贴板数据结构以支持这些新功能。
2026-10-21 22:18:30 +08:00
kolla be0fcb5795 fix(ui): keep the style picker open while editing a style's colour
The colour picker's Window area is keyed by egui as Id::new(Some(title)), but
the style picker looked it up as Id::new("color_picker"), so the lookup always
missed and any click in the picker counted as an outside click that closed the
editor (and the picker with it). Give the window a fixed id and query it through
colorpicker::window_id(), with a regression test.
2026-10-21 15:15:14 +03:00
Dylan M. Taylor 35da7b7cff feat(window): adopt shared craft-window for Linux decorations (depends on craft-libs#217) (#971)
* feat(window): adopt shared craft-window for Linux decorations

Replace FigCraft's bespoke Wayland xdg-decoration probe
(apps/figcraft/src/linux_window.rs) with the shared craft-window crate
from craft-libs, so every Craft app agrees on who draws the window frame
per desktop.

The decision is now made once, before the viewport is built, by
craft_window::Choice::probe(Prefix::new("FIGCRAFT")) and mapped onto
FigCraft's existing TitleBar enum:

  Frame::Desktop => TitleBar::Native   (TitleBar::Inset on macOS, unchanged)
  Frame::App(_)  => TitleBar::Custom

This drops the old runtime send_viewport_cmd(Decorations(..)) switch and
the linux_window probe. macOS keeps its inset traffic lights and Windows
keeps the system frame exactly as before. The only behaviour change: on
KDE and other Wayland compositors with server-side decorations (Sway,
Hyprland, COSMIC) FigCraft now keeps the native frame instead of always
drawing its own custom title bar, matching the rest of the suite.

NOT MERGEABLE YET: craft-window is pinned to the unmerged feature branch
from storytold/craft-libs#217 and must be repointed to a merged rev
before this can land.

* fix(window): preserve Windows titlebar; pin craft-window to merged rev

- Keep FigCraft's own custom title bar on Windows. craft-window returns
  Frame::Desktop on Windows, and the mapper was turning that into
  TitleBar::Native, flipping Windows from its existing undecorated custom
  bar to the system frame. Map Frame::Desktop to Custom on Windows (as
  macOS maps to Inset); only Linux/BSD Frame::Desktop becomes Native. This
  keeps macOS and Windows unchanged; the sole change stays KDE/Wayland.
- Repoint craft-window from the (now-merged) feature branch to the squash
  commit on storytold/craft-libs (craft-libs#217), and commit Cargo.lock.

Verified: cargo build -p figcraft and cargo test -p figcraft (53 tests)
pass against the merged dependency; rustfmt clean.
2026-10-21 06:05:08 -04:00
BFlat 74c4a84da3 Merge pull request #907 from RiceDee/fix/idle-tests-font-scan
test(ui-egui): idle harness scans system fonts before its first frame
2026-10-21 16:32:52 +07:00
BFlat 123e46550f Merge pull request #909 from RiceDee/fix/component-set-name
#903 follow-up: name new component sets from the variant path, not the first property
2026-10-21 16:31:04 +07:00
BFlat 44e64d8d47 Merge pull request #933 from SiadeLucas/feat/pages-scroll-area
ui: keep the current page scrolled into view in the pages list (#930)
2026-10-21 16:21:38 +07:00
BFlat bb532a5790 Merge pull request #908 from RiceDee/feat/903-variants-discoverability
#903: Combine as variants in the context menu and Design panel; Add variant on a lone component
2026-10-21 16:01:25 +07:00
BFlat f6f44dcc53 Merge pull request #928 from AganzeFelicite/fix/web-drop-opens-documents-916
M7: a document dropped on the canvas opens as a new file (#916)
2026-10-21 15:50:24 +07:00
BFlat 96a82edecf Merge pull request #939 from YarilaFox/fix/comment-draft-on-tool-switch
M7: dismiss comment draft when switching tools
2026-10-21 15:48:42 +07:00
BFlat f143260c3a Merge pull request #935 from YarilaFox/fix/saved-document-title
M7: show the chosen filename for saved documents
2026-10-21 15:47:09 +07:00
BFlat bb93e5e433 Merge pull request #929 from Silvertree2010/fix/h881-local-from-link-accepts-a-sign-i
Reject non-hex characters in Local::from_link token
2026-10-21 15:44:45 +07:00
BFlat da96d54157 Merge pull request #961 from Silvertree2010/fix/h1145-reversesubpaths-reverse-subpaths
Keep open subpaths open in ReverseSubpaths::reverse_subpaths
2026-10-21 15:35:17 +07:00
BFlat a1758c163f Merge pull request #958 from Silvertree2010/fix/h1112-live-text-svg-export-capitalises
Fix Title Case restarting mid-word across styled runs in SVG export
2026-10-21 15:34:55 +07:00
BFlat b1d00f0802 Merge pull request #943 from Silvertree2010/fix/h932-trim-num-prints-any-whole-number
Fix trim_num saturating whole numbers beyond i64 range
2026-10-21 15:34:34 +07:00
BFlat efb95b29bf Merge pull request #938 from Silvertree2010/fix/h911-same-server-ignores-the-scheme-s
Strip default port in same_server when the address has a path
2026-10-21 15:34:08 +07:00
BFlat 9257f97d69 Merge pull request #911 from Silvertree2010/fix/h737-parse-css-accepts-a-malformed-al
Reject malformed alpha and extra args in Color::parse_css
2026-10-21 15:33:47 +07:00
AKY 9cbe31221a Record Korean translation coverage checks
Use only root-supplied Korean translations; overlapping unmerged PRs are not dependencies. Preserve existing main translations. Static catalog, placeholder and source-lookup checks passed. Rust and native/web UI tests were not run because Rust installation remains awaiting approval.
2026-10-21 13:56:38 +09:00
AKY 1f78ca6aba Add Korean support
Preserve existing Korean translations and exclude unused keys and keys already proposed in open PRs.

Static catalog, key, placeholder, ellipsis, shortcut and whitespace checks passed. Cargo fmt, clippy, tests, xtasks and native/web UI validation were not run: Rust tools are unavailable and installation awaits approval. See docs/korean-translation-validation.md.
2026-10-21 13:34:40 +09:00
AganzeFelicite d68a2c54c5 Merge remote-tracking branch 'upstream/main' into fix/adobe-fonts-macos-298
# Conflicts:
#	ROADMAP.md
#	crates/text/src/fontdb.rs
#	crates/text/src/fontdb_tests.rs
#	docs/roadmap.md
2026-10-21 05:36:34 +02:00
NetterSilverandClaude Haiku 5.5 2e12bdfd91 Keep open subpaths open in ReverseSubpaths::reverse_subpaths
Found while testing crates/geom/src/lib.rs:287

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 02:46:23 +02:00
Siade Lucas f53f37dfe0 ui: scope page tracker by document UID and update on render
Include document UID in pages_seen_page to correctly handle page selection and scroll tracking when switching between documents. Record page as seen only after the active row renders so that pages filtered out by search scroll into view when the filter is cleared.
2026-10-20 21:28:09 -03:00
NetterSilverandClaude Haiku 5.5 503b6d606d Refuse to save documents containing NaN or infinite numbers
Found while testing crates/format/src/lib.rs:256 (to_json/write_json/save_bytes) and :323 (parse_json)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 01:27:12 +02:00
NetterSilverandClaude Haiku 5.5 59f3b3b733 Fix Title Case restarting mid-word across styled runs in SVG export
Found while testing crates/svg/src/lib.rs:589 (also :604 and :611)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 00:31:03 +02:00
tylerjenningsw 20f7d4eb2c test(canvas): grow the cold-preview scene instead of assuming a 50 ms render (M8)
The threaded test asserted a fixed glass scene renders in over 50 ms,
which would flake on a fast machine or once the glass speedups land.
It now grows the scene until the full render takes at least twice the
threshold plus the preview's own time, so the watchdog's preview comes
first with a margin wherever it runs.

Refs #295
2026-10-20 16:38:27 -04:00
tylerjenningsw f80bdb634d docs: zoom previews for cold transitions (M8)
Refs #295
2026-10-20 16:38:27 -04:00
tylerjenningsw 79d436a469 perf(canvas): preview cold zoom and page transitions once they run long (M8)
Whether a zoom got a preview rested on the previous render from
scratch, so the first render, another page or file, and a zoom from a
quick view to a heavy one came without one: the page colour showed
until the full render was done.

Rendering every such preview up front would cost 8-56% of a full render
(bench pages of 1k-50k layers at 2880x1800; per-layer costs dominate
large pages). Instead the render thread arms a watchdog thread as it
starts a render from scratch without a preview; once that render has
run past 12 ms the watchdog renders the preview beside it, on one core
while the full render keeps the others, and sends it unless the full
image is done. Quick renders cost nothing and never flash a preview.
Warm slow zooms keep the preview first, which keeps up with a gesture.

With two threads sending, finished images carry their request's
number: a poll takes the newest request's, and of one request the
refined image; the canvas never shows a preview over the refined image
of its own or a newer request.

Refs #295
2026-10-20 16:38:14 -04:00
Hamza 1aa1d61bd4 MCP: get_node keeps to the limit too, and windows a wide node
inspect_document sends a shallower tree when the document's is too large
and points at get_node for the rest, so get_node was the way out of that
limit -- and it had none of its own. Measured against a document with a
frame of 15,000 children, through figcraft-cli mcp:

    inspect_document depth 64, allPages   14.29 MiB   (8.00 MiB cap not
                                                       applied before #915)
    get_node depth 64                     10.51 MiB

Both are over the 10 MiB buffer the report is about, and for get_node no
depth makes it smaller: the width is at depth 1, so depth 1 is already
9.12 MiB of text and depth 0 has no children at all.

get_node now takes the same maxBytes, cuts the subtree by depth as the
document's tree is cut, and then takes a window over the children when
even depth 1 is too large. The marker says how many children were shown
of how many, and the offset to ask for next; `offset` is a new argument
for reading the rest. After this:

    get_node depth 64                     8.00 MiB, children 11427 of
                                          15000 from 0, next 11427

The hint in inspect_document's marker no longer promises that get_node
has no size limit, since that was the hole: it now says a wide node
answers with a window and an offset.

The test pages through a windowed node and asserts every child is
reported exactly once, in order, with every page inside the limit.
Unbounded, the first page is 23,003 bytes against a 7,667-byte limit.
2026-10-21 00:29:14 +05:00
Mex df5ffec87b M0.9: allow literal dash-prefixed CLI filenames 2026-10-20 21:25:49 +02:00
Hamza f3e5d2abc4 .fig import: a note says how much of the file it covers
A .fig import's notes were deduplicated and left uncounted, so each kind
was named once with no sign of how much of the file it applied to: a
file whose every text layer lost a setting read exactly like one where a
single layer did. That is what made the loss in #467 invisible -- the
reporter saw one line while three kinds of data went, one of them on all
83 text layers.

The mapper now collects its notes through the same `Warnings` the REST
importer already uses, which keeps the first-seen order and the cap and
appends `(×n)` where a note was met more than once. Nothing else
changes: the note text stays the catalog key it was, and the import
report already reads a `(×n)` suffix off before translating the base
(`ui-egui`'s `note_text`), so a counted note is still translated.
`figcraft-cli info` prints these notes, so it now names each kind with
its count.

With the count dropped again, the test reports the two notes plain:

    ["connector layers import as static frame/vector snapshots ...",
     "connector label was skipped: depth limit prevents nested ..."]

Part of #467.
2026-10-21 00:06:57 +05:00
NetterSilverandClaude Haiku 5.5 b788b55286 Keep a solid paint's own opacity when binding a colour variable
Fixes #923

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:43:51 +02:00
NetterSilverandClaude Haiku 5.5 570144ee90 Reject multiple leading '#' in Color::from_hex
Found while testing crates/color/src/lib.rs:125

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 17:49:45 +00:00
NetterSilverandClaude Haiku 5.5 77f4271061 Fix trim_num saturating whole numbers beyond i64 range
Found while testing crates/doc/src/text.rs:64

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 17:03:45 +00:00
YarilaFox 8f3afbf6a1 M7: clear comment drafts for control-channel tool changes 2026-10-20 21:04:04 +05:00
Hamza 3df5673627 MCP: maxBytes is the whole message, and depth 0 says when it is over
Two things the limit got wrong, both reported by CodeRabbit on #915.

maxBytes was compared with the escaped text block alone, but what goes
on the wire is that block inside a content object, inside the JSON-RPC
result, with the request id and the newline the transport writes: a
block measured at exactly maxBytes left as a larger message than the
caller said it could read. A kilobyte of the caller's figure is now kept
for that wrapping. About ninety bytes of it is fixed and the rest is the
request id, so the figure holds for any id up to roughly 900 bytes --
every id in practice, a number or a UUID. Sizing the real envelope here
would mean handing the transport's request id to every tool, and the
assumption is written down instead, in the schema and at the constant.

A depth: 0 request over the limit returned early, so the one response
that cannot be made any smaller was the one sent without the truncated
marker -- while a bisection that walked down to depth 0 sent the same
tree with it. Depth 0 now takes the same path as the bisection: the tree
goes out, over the limit, with the marker saying so.

Without the first fix the test reports a limit at the exact size of the
tree as no truncation; without the second the depth-0 marker is absent
(left: (None, None), right: (Some(0), Some(0))).
2026-10-20 20:56:08 +05:00
Siade Lucas 1c79c1646f ui: scroll active page into view when selection changes 2026-10-20 12:53:21 -03:00
YarilaFox bf3fc3cc6e M7: dismiss comment draft when switching tools 2026-10-20 20:47:16 +05:00
NetterSilverandClaude Haiku 5.5 8b198ea364 Strip default port in same_server when the address has a path
Found while testing crates/engine/src/online.rs:118

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 15:41:47 +00:00
Brandon ThomasandClaude Opus 5.5 d9c924c3ba Skip CodeRabbit reviews on Dependabot PRs (#937)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 11:39:30 -04:00
Dylan M. Taylor 92290dd208 docs: add SonarCloud and CodeRabbit badges to README (#820)
* docs: add SonarCloud and CodeRabbit badges to README

* docs: put SonarCloud and CodeRabbit badges on their own line
2026-10-20 11:32:08 -04:00
YarilaFox 6165d2ec85 M7: preserve bindings and design layers when pasting images 2026-10-20 20:12:47 +05:00
Lucas 728f5c48ef i18n(pl): add Polish labels for remaining 7 engine commands
- Add Polish command labels for arrange.reorder, file.moveTab, pref.startOnHome,
  pref.uiScale, text.loadFont, text.refit, and text.rescanFonts.
- Ensures 100% command coverage with zero fallback to English in the Actions palette.
2026-10-20 16:46:58 +02:00
NetterSilverandClaude Haiku 5.5 7c68ebf183 Reject non-hex characters in Local::from_link token
Found while testing crates/collab/src/code.rs:~150 (Local::from_link, u64::from_str_radix on the token)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 14:27:09 +00:00
Lucas cabe2e3cfb i18n(pl): native review, catch-up and terminology fixes
- Mark Polish catalog as reviewed and approved by native speaker (@lukasz-sklad)
- Replace "Lekki" with "Jasny" for Light mode
- Translate disambiguated context keys (Fill|sizing, Join|stroke, Light|glass, Open|comments)
- Catch up with 15 UI strings added in recent main commits
- Update docs/translating.md to list Polish as reviewed
2026-10-20 15:58:55 +02:00
AganzeFelicite fc37c808aa M7: a document dropped on the canvas opens as a new file (#916)
A .figcraft, .fig or file JSON dropped on the canvas ran file.import,
which added its pages to the file in front. It now opens as a file of
its own, as on the home screen, in the browser too. Images and SVG
still import at the drop point; File > Import still adds pages.

- pending_open::open_dropped queues a dropped file as file.open; the
  home screen and the canvas share it.
- Dragging only documents over the canvas says "Drop to open" (not in
  the browser, which names no file until the drop).
- The tab-switch import test drops an SVG: it pinned where an import
  lands, using a .figcraft only as a file to import.
2026-10-20 15:27:27 +02:00
YarilaFox a559638419 M7: avoid Windows GNU UIA crash 2026-10-20 17:57:10 +05:00
YarilaFox 23463f719b M7: keep saved document titles in sync with filenames 2026-10-20 17:56:30 +05:00
YarilaFox 31da698dd5 Fill selected layer when pasting an image 2026-10-20 17:55:08 +05:00
Hamza b1f0eafbf7 MCP: inspect_document answers within what a client can read
MCP stdio is newline-delimited JSON, so one tool result is one message
and has to fit the client's read buffer whole; there is no streaming of
part of one. The TypeScript SDK's stdio transport defaults to 10 MiB and
hosts rarely raise it, so a document whose tree went past that killed
the server: the client stopped reading, the pipe broke, and every later
call in the session failed.

The tree is now reported at the deepest depth that fits, found by
bisection, with a `truncated` object giving the depth asked for, the
depth used, both sizes, the limit and a pointer to get_node for reading
one subtree deeply. `maxBytes` lets a caller that knows its own buffer
say so; the default is 8 MiB, leaving the envelope 2 MiB of the usual
10.

The size is measured as the transport sends it -- pretty-printed and
then escaped inside the text block, which adds about a tenth (342,136
bytes of tree became 379,954 on the wire in the fixture). A kilobyte is
reserved for the marker itself, which is added after the measurement.

A limit below even the shallowest tree still answers, at depth 0, with
the marker saying so: there is nothing smaller to send, and an error
would leave the caller with nothing at all.

The tool description now says that each level of depth multiplies the
response and points at get_node, which the report notes would have been
enough on its own to avoid the crash.

Fixes #912.
2026-10-20 16:30:03 +05:00
NetterSilverandClaude Haiku 5.5 49586b1c47 Reject malformed alpha and extra args in Color::parse_css
Found while testing crates/color/src/lib.rs:165 (also :173)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 09:30:01 +00:00
RiceDee b5a315a748 #903: name a new component set from its variants' path, not their first property
A component set took its name from the first variant's name up to the first '/' or '=',
so variants named "State=Default, Size=Small" made a set called "State". The set now
takes the path before the last '/' ("Button/State=Hover" -> "Button",
"Icons/Arrow/Dir=Up" -> "Icons/Arrow"), keeps a plain name as is ("Button"), and
otherwise gets the next numbered default ("Component 1"), as a new component does.
2026-10-20 16:32:16 +11:00
RiceDee bcd5b6e632 #903: Combine as variants in the context menu and Design panel; Add variant on a lone component 2026-10-20 16:18:13 +11:00
叶林橦 3cbf3aec48 Merge remote-tracking branch 'origin/main' into feat/popover 2026-10-20 13:06:58 +08:00
叶林橦 1472d4f03f Merge origin/main into feat/panel-grid: resolve conflicts
- right.rs: drop the unused ACCENT import (main removed it); keep the grid
  constants CAPTION_PT/TEXT_PT/TITLE_PT our branch added.
- typography.rs: keep our TEXT_PT size, adopt main's accent_text token rename.
- widgets.rs: keep our TEXT_PT size for the button label; the disabled colour is
  now main's text_tertiary from the match, so the old dim() closure is dropped.
- ui-parity.md: keep our row and main's two (#890, #894) changelog rows.
2026-10-20 13:04:45 +08:00
RiceDee bbe1a12eeb test(ui-egui): idle harness scans system fonts before its first frame 2026-10-20 15:29:27 +11:00
叶林橦 50b978b8f9 M4: the colour picker closes like every other detached panel
The picker draws on the shared popover, so it closes the way the popover does: the X,
Escape, and a press outside it.

- A press on the canvas closes it; presses in the panel beside it keep it, since a
  swatch, a style chip or a field there switches the colour it edits.
- The control that opened it is held out of that closing and its rect is refreshed every
  frame, so pressing the same swatch again reaches its own toggle — after the panel
  scrolls too (the bug #730 had, in the picker's hands).
- Escape closes it before the binding deselects the layer, and not while one of its own
  dropdowns is open.

Tests: a press on the canvas closes it, the same swatch closes it again (panel_remove_tests).
2026-10-20 12:24:09 +08:00
叶林橦 a18413b623 M4: the Design panel's sections on one 240 pt grid
Every section lays its rows out on the same grid: the panel is 240 pt, its content
16 pt in from the left and 8 pt from the right, two 88 pt columns at that width with
8 pt gutters and a 24 pt column for the buttons at a row's end (the columns are flex,
so they share a wider panel); rows are 24 pt in a 32 pt track, captions 10 pt under
them, labels and values 12 pt, the selection's type above them 14 pt. A row's two
buttons (eye, minus) are one group on the row's right edge, 24 pt each and 4 pt apart.

Two things put the right margins out before: block() inset every row by PAD on both
sides (16/16 instead of 16/8), and the panel's frame carried a 1 pt stroke (egui counts
a frame's stroke as its margin), which took a point off each side of the 240 pt panel.
The edge is drawn inside the panel now.

The paint, effect and layout grid rows reserve the whole button group before their
field (8 + 24 + 4 + 24 = 60), so the group lands on the panel's 8 pt margin; the export
preview frame takes the same 16 / 8. Every row that had its own width now asks the
grid: the stroke's align and weight fields split what the row leaves (74 + 8 + 74 with
two buttons, 88 + 8 + 88 with one), the type panel's size field and the full-width
dropdowns take the row's own width, and the Inspect tab's rows end at the 8 pt margin.

The fields' label inset is 8 (was 12) so "Intensity" fits its column whole beside its
value; the glass field's width in field_tests follows the wider column.

Tests: cargo test -p figcraft-ui-egui (549 passed).
2026-10-20 12:19:27 +08:00
叶林橦 3862a2db88 M4: the colour swatches toggle the picker; the pickers' review fixes
Clicking a colour swatch opens the picker; clicking the same one again closes
it (another swatch switches the picker to its paint). The page background, the
text range, the paint rows, the effect rows and the selection colours all
toggle.

The pickers' review fixes on top: the colour picker keeps its popover only
while it is open (a body that closed it left a stale dragged place for the next
picker to inherit); the style picker resets its retained popover when a new
picker opens and on its early-close paths; and Escape belongs to the nested
colour picker first, so closing it doesn't take the style picker down with it.

Opening the style picker, the layout settings or the stroke settings closes the
colour picker instead of leaving it lingering beside them.
2026-10-20 11:57:49 +08:00
叶林橦 345a07fb8f M4: the popover every detached panel shares
`Popover` is the one card detached panels draw on: a header 40 points high
with the title 16 in from its left edge and a 24-point X 8 in from its right,
a hairline under it, the theme's popup shadow, and one slot. Its measurements
are our own — the colour picker's header, which it now uses — not another
product's.

The colour picker and the style picker move onto it in this commit; each keeps
only its own body. The style picker keeps its dark surface through
`Surface::Menu`, and its tools row (view, detach, create) now starts the slot,
under the shared title.

Closing is the widget's now: the X, Escape, and a press outside it — except in
the rects the caller holds out (the button that opened it, a popover it opened
in turn), so that button's own click can toggle it shut instead of the press
closing it and the release opening it again (the bug #730 has). The colour
picker keeps its old habit of staying open while the panel beside it is used.

Tests: press-and-release for the X, for a press on the opener (open) and for a
press elsewhere (closed), plus Escape, the opening rule, the lift and the drag.
2026-10-20 11:57:49 +08:00
叶林橦 e9a0c437e2 M4: a generic popover — a 240 pt card with a 40 pt header
The chrome detached panels share, in one place: a 240 pt card with a 40 pt header —
the title 16 in from its left edge, a 24 pt X 8 in from its right — a hairline under
the header and the theme's popup shadow, around one slot:

    let mut p = Popover::new("my_popover", tr("Title"));
    let closed = p.show(ctx, Open::Anchor(pos), Hold::default(), |ui| { /* the slot */ });

The measurements are the ones the colour picker's header already used, which this widget
now draws; the card is as wide as the picker was. The shadow is the theme's
(`visuals.popup_shadow`), so the dark theme gets its own.

Closing is the widget's: the X, Escape, and a press outside it — except in the rects the
caller holds out (`Hold { rects, escape }`: the button that opened it, a popover it opened
in turn), so that button's own click can toggle it shut instead of the press closing it
and the release opening it again (the bug the fill-rule panel has). Press-and-release
tests pin all of it.

It opens against an anchor (its right edge on the anchor, top on it, lifted when that
would run off the screen's bottom) or at a point; the header drags it.

Six headless tests: the header's geometry, the opening rule, the lift, the drag, the X,
Escape, and the press-and-release of a press on the opener (open) and elsewhere (closed).
2026-10-20 11:57:48 +08:00
xiaou61 beb87d8696 M1: describe the fallback the export actually takes (#465)
The warning still said fixed and content-sized tracks were written as equal
tracks, which the export no longer does. It now reports only the tracks whose
size the format can't hold, and only for the tracks an exported grid has.

Co-authored-by: xiaou61 <[email protected]>
2026-10-20 11:14:02 +08:00
xiaou61 ea2fa7c367 M1: say that the automation roots reach only the headless side (#835)
The CLI help and docs/mcp.md described the roots as confining whatever the run
touches, which reads as if they also applied to `mcp --attach`. They don't: an
attached server runs the commands inside the app, and the app confines them
from its own flags. VectorCraft documents the same split, and the missing
sentence is what made the difference confusing, as #835 reports.

Co-authored-by: xiaou61 <[email protected]>
2026-10-20 11:08:21 +08:00
xiaou61 414aa1b63c M1: write grid track sizes in .fig export (#465)
The import has read `gridColumnsSizing` / `gridRowsSizing` since #630, but the
export wrote only equal tracks and warned about it, so a grid with a fixed
column beside two weighted ones came back equal after a save and reload.

Write the sizing map next to the order map, keyed by the same track ids: fixed
px, fractional fill and content-sized tracks keep their sizes. A track the
format has no name for (negative or out-of-range size, or a document track the
frame doesn't list) keeps its id and reads back equal, with the existing
warning.

Co-authored-by: xiaou61 <[email protected]>
2026-10-20 11:04:13 +08:00
Brandon ThomasandClaude Opus 5.5 594dc8c6cf Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:20:57 -04:00
BFlat dd6bd3fe1c Merge pull request #901 from pechnikov2026-star/community/russian-catch-up
M9: Russian catalog catch-up: the 56 strings added since the review (machine-drafted)
2026-10-20 06:32:45 +07:00
bflatastic 9b711e212b Merge main into #901 2026-10-20 06:20:04 +07:00
BFlat b5038726c4 Merge pull request #902 from storytold/fix/issue-892
M3: duplicating a main component makes an instance (#892)
2026-10-20 06:19:17 +07:00
BFlat 9c6920cf7d Merge pull request #896 from johnpetersa19/main
Add missing Brazilian Portuguese UI translations
2026-10-20 06:12:04 +07:00
BFlat 2bcd004071 Merge pull request #897 from devius/ka-header
i18n(ka): say how the Georgian catalog was made (AI-assisted, native-reviewed); still a draft
2026-10-20 06:11:27 +07:00
bflatastic f979878d9e Merge main into fix/issue-892
# Conflicts:
#	ROADMAP.md
2026-10-20 06:09:17 +07:00
BFlat 5f0fe486db Merge pull request #900 from storytold/fix/issue-890
M1: readable disabled and selected text (#890)
2026-10-20 06:09:13 +07:00
sanches eb7ead9455 M9: Russian catalog catch-up: the 56 strings added since the review (machine-drafted) 2026-10-20 01:59:51 +03:00
bflatastic 64efe12e6f Merge main into fix/issue-890
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-20 05:58:56 +07:00
BFlat 4bb30782ff Merge pull request #899 from storytold/fix/issue-879
M1: node.get reports layer data (pluginData) (#879)
2026-10-20 05:58:32 +07:00
bflatastic ab59877559 Merge main into #897 2026-10-20 05:47:17 +07:00
bflatastic 77ffc57432 #896: "Individual padding" uses the catalog's word for padding (Preenchimento is Fill) 2026-10-20 05:47:15 +07:00
bflatastic 4e7256bf6a Merge main into #896 2026-10-20 05:47:14 +07:00
bflatastic 8a46478e81 Merge main into fix/issue-879
# Conflicts:
#	ROADMAP.md
2026-10-20 05:46:30 +07:00
BFlat 11ecf3e887 Merge pull request #898 from storytold/fix/issue-894
M1: nudge while measuring; distances follow, handles hide (#894)
2026-10-20 05:46:25 +07:00
bflatastic b82369201d M3: duplicating a main component makes an instance (#892)
Duplicate and Alt-drag copied a main component as a second main. Within
its file the copy is now an instance of it (components::copy_in_file,
shared by edit.duplicate and the Alt-drag copy), as Copy then Paste
already was. A variant duplicated in its set stays a new variant.
2026-10-20 05:44:46 +07:00
bflatastic e3b20b6540 Merge main into fix/issue-894
# Conflicts:
#	ROADMAP.md
2026-10-20 05:35:21 +07:00
BFlat b17bea9195 Merge pull request #881 from yelintong/feat/grid-auto-width
Layout grids: Auto line width for columns and rows
2026-10-20 05:35:13 +07:00
BFlat 287b506bc1 Merge pull request #889 from kk376/fix/857-live-session-large-files
fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
2026-10-20 05:34:52 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 e123a2efe7 i18n(ka): the draft status says the review is done and approval pending; the guide and the field doc say both kinds of draft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-20 02:33:25 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 bae2f057a1 i18n(ka): the catalog header and docs say how Georgian was made (AI-assisted, native-reviewed); still a draft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-20 02:11:55 +04:00
bflatastic 05da6b2125 M1: node.get reports layer data (pluginData) (#879)
Layer data written with node.set {props: {pluginData}} was saved but no
command read it back. node.get (the command, the control method and MCP
get_node) and document.inspect now report pluginData as {key: value}
when a layer or page has any, so a tool keeping state in a file can
read it on its next run. Widget keys are included: the plugin rules
only stop plugins writing them, and widget.list already reports that
state.
2026-10-20 04:58:32 +07:00
bflatastic 5617c94167 M1: nudge while measuring; distances follow, handles hide (#894)
Alt+arrows were left out of the nudge keys, so with Alt held to measure
the arrows did nothing and the distances never changed. The arrows now
nudge with Alt held too (Shift: the big step); the hover measurement is
recomputed every frame, so the labels follow each nudge. While measuring
to another layer the selection's handles and size label hide; both
layers keep their outlines.
2026-10-20 04:58:31 +07:00
John Peter Sa c3fe69c629 Merge branch 'storytold:main' into main 2026-10-19 18:58:22 -03:00
bflatastic 9ee0c7f572 M1: readable disabled and selected text (#890)
The dim text token (disabled buttons, placeholders, hints) was 2.1:1 on
the light theme's panels; disabled buttons also faded to 40% opacity,
and selected text was drawn in the dark green, 1.4:1 on the dark
theme's selection highlight. The dim text token is now at least 3:1
against panels, fields and selected rows in both themes while staying
dimmer than secondary text, a disabled button is grey with that text,
and selected text takes the theme's readable green. A test checks the
ratios with the colour contrast helper.
2026-10-20 04:57:20 +07:00
johnpetersa19andCopilot App 629c297de9 M2.1: add missing Brazilian Portuguese translations
Co-authored-by: Copilot App <[email protected]>
2026-10-19 18:49:06 -03:00
bflatastic 0f95ce3d5b #881: test Auto in a fixed grid's count, scroll to the field instead of a fixed point 2026-10-20 04:45:45 +07:00
bflatastic a7d0648958 #881: fixed grids keep their pixel Width and the Stretch->Left lift to 60; the stretched Auto width reuses the number field (disabled) 2026-10-20 04:45:44 +07:00
bflatastic 309271851f #881: keep fixed grids at width 0 drawing bands as before (no hairlines) 2026-10-20 04:39:14 +07:00
bflatastic f99fdea3b6 #881: leave the progress docs to the maintainer 2026-10-20 04:39:13 +07:00
bflatastic 5d32c522d2 Merge main into #881 2026-10-20 04:39:04 +07:00
bflatastic cc4799061e #889: say a join is too large in words for people (native and web), translated; keep one test 2026-10-20 04:38:28 +07:00
BFlat 5b5a4855d4 Merge pull request #895 from storytold/fix/web-fonts
M9: web release draws text again; craft-fonts pin back to the Latin fonts (#885)
2026-10-20 04:38:11 +07:00
bflatastic ddde77db0a M9: release fonts: web build embeds craft-fonts, pin back to the Latin manifest (#885)
The browser has no system fonts, and since releases moved to the shared
workflow the web build was made without craft-fonts: no font list and no
text drawn. The Georgian change also re-pinned craft-fonts to its main,
which has no fonts/latin-manifest.txt, so the next release would fail
under CRAFT_FONTS_REQUIRED. Pin the Latin commit again until one commit
has both (craft-fonts#42); desktop Georgian uses an installed font.
2026-10-20 04:26:04 +07:00
bflatastic 89529b3d00 #889: drop the hub write-cap and core-limit changes (neither is read) 2026-10-20 04:22:26 +07:00
bflatastic ca0e689834 Merge main into #889 2026-10-20 04:22:20 +07:00
BFlat 4c1546eed2 Merge pull request #888 from Silvertree2010/fix/886-when-drag-n-dropping-multiple-fi
Place files dropped together on the canvas side by side
2026-10-20 04:21:10 +07:00
BFlat b19a81084e Merge pull request #813 from alrifatsabbir/bengali-ui-translation
Add Bengali UI Translation
2026-10-20 04:10:12 +07:00
BFlat 20a16dfd5c Merge pull request #872 from devius/ka-language
i18n: Georgian (ka) interface language
2026-10-20 03:57:09 +07:00
JimKnoxxandClaude Opus 5.5 4a73289a91 M5: presentation tabs: keep them off the window controls, close X on every frame
Presentation tabs now take at most half the free strip while file tabs share it.
They shrink to the minimum tab width first, then the rest go into an overflow
menu, so they never reach the new-tab button, theme toggle or window buttons.
The close X has its own hit rect next to the tab body and is registered every
frame, so a click on it can no longer select the tab instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:34:27 +02:00
bflatastic 40fe1a623e Merge fix/872 into #813 2026-10-20 03:26:53 +07:00
bflatastic 4b030f027a Merge main into #872 2026-10-20 03:20:30 +07:00
JimKnoxxandClaude Opus 5.5 bbe691cb43 M5: presentation tabs: test both owner guards, document the tab functions
Review follow-up. The closed-owner test now picks a device and switches
Responsive in the presenter and checks that the file in front keeps its
prototype settings, so removing either `live` guard in present::show fails
it. The present harness gets frame_output for the AccessKit tree. The new
tab functions get doc comments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:15:25 +02:00
JimKnoxxandClaude Opus 5.5 8d5a29dfda M5: one presentation tab per page of a file
Main plays a presentation in a single "Present" tab. Now each page of a
file gets its own tab, named "file - page", and the name follows
renames. Play on a page that has a tab shows that tab as it was; from
another start frame it starts again. A tab keeps its own copy of the
prototype: it plays on after its file closes, and a saved file opened
again gets its tabs back. A tab whose file is closed or not in front
never reads or changes the file in front (device, responsive,
comments). Escape or the tab's X closes that tab and goes back to its
file, or Home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:15:13 +02:00
Kushagra Kumar 6369b6b7a5 test(collab-net): exercise NetLink host in large welcome regression test 2026-10-20 01:43:50 +05:30
BFlat 9d9cb9915a Merge pull request #812 from lukasz-sklad/feat/polish-translation
feat(i18n): Complete Polish translation (pl)
2026-10-20 03:05:06 +07:00
BFlat 1abb9083c7 Merge pull request #821 from attisvalentin/fix/rescan-and-load-fonts
M4: fonts installed while the app runs: text.rescanFonts, text.loadFont, text.refit
2026-10-20 03:04:29 +07:00
BFlat 4788c6b819 Merge pull request #870 from MortenHusted/fix/component-paste-instance
M3: copied components paste as instances within their file
2026-10-20 03:04:03 +07:00
Kushagra Kumar e8a6033199 test(collab-net): add ignored loopback test exercising welcome delivery above 64 MiB 2026-10-20 01:33:32 +05:30
BFlat 54e3587fd8 Merge pull request #862 from MortenHusted/fix/instance-main-modes
M3: instances follow the variable modes their main component sets
2026-10-20 03:03:27 +07:00
BFlat ba4a736d5b Merge pull request #847 from MortenHusted/fix/fig-component-props
M3: .fig imports keep component property bindings and values
2026-10-20 03:02:50 +07:00
BFlat 163e840fc3 Merge pull request #818 from leandrodslv/feat/ui-size
feat(ui): add the interface size preference (#540)
2026-10-20 03:01:28 +07:00
BFlat b5904ad4c2 Merge pull request #874 from justinjohn0306/fix/857-start-on-home
M7: a preference to start on the Home screen (#857)
2026-10-20 03:00:49 +07:00
BFlat 413ec40932 Merge pull request #867 from AganzeFelicite/fix/fig-paragraph-returns-852
fix(text): a line separator breaks the line inside its paragraph (#852)
2026-10-20 03:00:11 +07:00
BFlat d32e971685 Merge pull request #832 from Ahmed5Emad/seperate-padding
adding individual padding controls in Layout panel
2026-10-20 02:59:33 +07:00
BFlat f8fc12b981 Merge pull request #854 from campbellcody/feat/smart-selection-drag-reordering
M1: smart-selection drag reordering
2026-10-20 02:58:55 +07:00
BFlat e4e872a489 Merge pull request #861 from AJMakesStuff/#855--The-Search-button-for-the-pages-doesn't-work-as-expected
#855: The Search button for the pages doesn't work as expected
2026-10-20 02:58:18 +07:00
BFlat d43375b69c Merge pull request #865 from AganzeFelicite/fix/delete-page-key-859
fix(pages): Delete deletes the page picked in the Pages list (#859)
2026-10-20 02:54:33 +07:00
Kushagra Kumar 227e182f68 fix(collab-net): report exact bytes when server message limit is not a whole unit 2026-10-20 01:23:09 +05:30
bflatastic f6090dde80 Merge #813's latest head (main merged in, contributors entry)
# Conflicts:
#	crates/text/build.rs
2026-10-20 02:44:56 +07:00
Kushagra Kumar fbb4a22e01 fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
- Configure server write loop in hub.rs with max_server_message so outgoing ServerMsg payloads above 64 MiB (e.g. document snapshots in Welcome messages) are delivered without socket termination.
- Set core max_message_bytes in local live session HostConfig to max_server_message.
- Map tungstenite Capacity errors in guest read loop to Outcome::Failed with accurate size limits rather than Outcome::Refused which falsely reported 'The host refused'.
- Add loopback tests verifying Welcome delivery above client message limits and accurate diagnostics on client capacity overrun.
2026-10-20 01:12:13 +05:30
NetterSilverandClaude Haiku 5.5 7d0ecd2294 Place files dropped together on the canvas side by side
Fixes #886

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 19:17:28 +00:00
bflatastic 948c6fb8e6 Merge main into #813 (via #872)
# Conflicts:
#	docs/translating.md
2026-10-20 02:06:07 +07:00
bflatastic da1f0a0484 Merge #812 into #872
# Conflicts:
#	docs/translating.md
2026-10-20 02:05:46 +07:00
bflatastic baf93b7756 Merge #874 into #872
# Conflicts:
#	docs/mcp.md
2026-10-20 02:05:13 +07:00
bflatastic 9c2988d408 Merge main into #872
# Conflicts:
#	crates/text/build.rs
2026-10-20 02:04:57 +07:00
bflatastic c13c4301be Merge main into #812 2026-10-20 02:04:36 +07:00
bflatastic df99f364a7 Merge main into #821 2026-10-20 02:04:35 +07:00
bflatastic f22e2ed825 Merge main into #870 2026-10-20 02:04:33 +07:00
bflatastic 4e8f258300 Merge main into #862 2026-10-20 02:04:32 +07:00
bflatastic 8aaff7bc22 Merge main into #847 2026-10-20 02:04:27 +07:00
Al Rifat Sabbir a8fa1f0fb4 Add Al Rifat Sabbir to contributors list 2026-10-20 01:01:50 +06:00
bflatastic e8b7917002 Merge #874 into #818
# Conflicts:
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/locales/fr.toml
2026-10-20 01:43:53 +07:00
bflatastic 7b478052d1 Merge main into #818 2026-10-20 01:43:44 +07:00
bflatastic bc35f02ecb Merge main into #874 2026-10-20 01:43:43 +07:00
bflatastic 66a8227bb4 Merge main into #867 2026-10-20 01:43:41 +07:00
bflatastic 7814c32a05 Merge main into #832 2026-10-20 01:43:40 +07:00
Al Rifat Sabbir fbc660b8b7 Merge branch 'main' into bengali-ui-translation 2026-10-20 00:43:24 +06:00
bflatastic 08880f6e26 Merge main into #854 2026-10-20 01:34:14 +07:00
bflatastic 704eb1cc0a Merge main into #861 2026-10-20 01:34:13 +07:00
bflatastic d54d107924 Merge main into #865 2026-10-20 01:34:10 +07:00
bflatastic cbb95c6412 Merge main into #854 2026-10-20 01:33:53 +07:00
bflatastic 7b22438739 Merge main into #861 2026-10-20 01:33:51 +07:00
BFlat a1369310d4 Merge pull request #880 from YarilaFox/feat/figcraft-brand
M7: refresh FigCraft brand colors and wordmark
2026-10-20 01:25:12 +07:00
bflatastic 85c036624d Merge main into #865
# Conflicts:
#	contributors/people.toml
2026-10-20 01:21:22 +07:00
BFlat b9bfc51273 Merge pull request #831 from YarilaFox/fix/tab-strip-overflow
M7: Keep tab strip usable with many files
2026-10-20 01:19:14 +07:00
bflatastic bf917a4c4f Merge main into #880
# Conflicts:
#	crates/ui-egui/src/toolbar.rs
2026-10-20 01:13:35 +07:00
BFlat b51c9b68d7 Merge pull request #866 from YarilaFox/fix/tab-engine-order
M7: Preserve active tab and support tab reordering
2026-10-20 01:11:21 +07:00
BFlat b23d6b917b Merge pull request #826 from slyim/fix/toolbar-icon-rhythm
M4: toolbar marks share one size and stroke weight; a simpler Slice icon (#737, #738, #739)
2026-10-20 01:08:37 +07:00
BFlat 54ddb2a200 Merge pull request #829 from Silvertree2010/fix/h2788-swiftui-compose-code-generation
Keep multiline layer names inside the SwiftUI/Compose too-deep comment
2026-10-20 01:07:34 +07:00
BFlat 6382be2197 Merge pull request #878 from Silvertree2010/fix/h636-live-parse-color-accepts-signs-i
Reject non-hex characters in live::parse_color
2026-10-20 01:06:57 +07:00
BFlat 4c21d2df39 Merge pull request #875 from Silvertree2010/fix/h599-overflowing-css-colour-channel-e
Clamp overflowing colour channels to 1 instead of 0
2026-10-20 01:06:20 +07:00
BFlat 3335612c12 Merge pull request #876 from Silvertree2010/fix/h632-invite-link-percent-decoder-acce
Reject non-hex characters inside invite link percent escapes
2026-10-20 01:05:44 +07:00
bflatastic 3afd896e2e Merge main into #876
# Conflicts:
#	crates/collab-net/src/invite_tests.rs
2026-10-20 00:48:30 +07:00
BFlat 0e0c3c7be4 Merge pull request #869 from Silvertree2010/fix/h565-server-address-parser-accepts-a
Reject server addresses whose port is not all ASCII digits
2026-10-20 00:47:23 +07:00
BFlat 3ed4578112 Merge pull request #851 from MortenHusted/fix/effect-unknown-keys
M3: effects refuse keys they don't read instead of dropping them
2026-10-20 00:46:45 +07:00
bflatastic 16f6b92519 Merge main into #880 2026-10-20 00:46:27 +07:00
BFlat f3c211eca5 Merge pull request #850 from MortenHusted/fix/make-slot-errors
M3: a refused slot names component.makeSlot
2026-10-20 00:46:07 +07:00
bflatastic 088693cfd3 #880: canvas overlays take the green that stands out on the page; white text stays on the darker green 2026-10-20 00:45:56 +07:00
bflatastic 9ea757736f #880: leave web font embedding off until the font pin carries Archivo 2026-10-20 00:45:55 +07:00
BFlat ab5a508538 Merge pull request #849 from MortenHusted/fix/css-stroke-sides
M7: CSS borders follow individual stroke sides
2026-10-20 00:45:39 +07:00
BFlat 8519af2539 Merge pull request #848 from Silvertree2010/fix/837-bug-control-port-equals-form-sil
Accept --control=PORT like --control PORT
2026-10-20 00:45:02 +07:00
BFlat 4f1da1ebe6 Merge pull request #842 from Silvertree2010/fix/h2814-variable-createcollection-accept
Refuse empty mode lists and blank names in variable.createCollection
2026-10-20 00:44:25 +07:00
bflatastic ec9bb5f978 #812: a shorter Intensity label that fits the glass field 2026-10-20 00:26:06 +07:00
bflatastic b29e17f5c0 Merge main into #842
# Conflicts:
#	crates/engine/src/variables_behaviour_tests.rs
2026-10-20 00:25:18 +07:00
BFlat 458cd5b141 Merge pull request #817 from muamerETS/fix/copy-text-as-plain-text
M1: copying text layers puts their text on the clipboard (#808)
2026-10-20 00:24:07 +07:00
BFlat 7ca8c80a8f Merge pull request #814 from muamerETS/fix/layout-remove-gap-binding
M3: removing auto layout drops its gap and padding bindings (#810)
2026-10-20 00:23:28 +07:00
bflatastic 20e4028afa #818: interface sizes are relative to the system's display scale (100% is native, Auto goes; 75-200% in Preferences, pref.uiScale takes 0.75-3, "auto" reads as 1); interface_size moved above batch_rename_dialog's doc comment; tests and docs follow 2026-10-20 00:23:14 +07:00
bflatastic a9975ccc22 #818: drop the progress-doc edits (ROADMAP revision row, ui-parity rows); the maintainer adds them 2026-10-20 00:13:55 +07:00
bflatastic 8c7f40fc18 Merge main into #818 2026-10-20 00:13:53 +07:00
bflatastic 21d5090db2 #874: --control starts in a new file whatever the preference (control callers expect a document), with a test 2026-10-20 00:13:47 +07:00
bflatastic 9877b8e6cc #874: test that the Start on the Home screen preference persists (old settings read off) 2026-10-20 00:13:46 +07:00
bflatastic 22ef4fdd99 #874: locale entries in sorted position 2026-10-20 00:13:45 +07:00
BFlat 409de1a762 Merge pull request #871 from justinjohn0306/fix/809-scalar-bound-edits
M3: explicit edits of bound scalar fields detach their variable (#809)
2026-10-20 00:10:09 +07:00
bflatastic e358c695cc #813: Bengali in script detection (cjk::ALL), restore the あ漢한 assert, doc comments, rustfmt 2026-10-20 00:05:51 +07:00
bflatastic 997444cdb6 Merge #872 into #813 2026-10-20 00:05:14 +07:00
bflatastic 3d0a93c1a1 Merge main into #813 2026-10-20 00:04:56 +07:00
bflatastic ce615ada10 #872: ka.toml keys in en.toml's order, commands sorted 2026-10-20 00:03:30 +07:00
bflatastic 97f2c99d6b #872: the web release leaves Georgian out; one pref.language line in docs/mcp.md 2026-10-20 00:03:02 +07:00
bflatastic 381ec85203 #872: keep the web release without craft-fonts (Georgian is desktop-only for now) 2026-10-20 00:02:47 +07:00
bflatastic 7543759150 #872: leave the progress docs (ROADMAP, localization-parity) to the maintainer 2026-10-20 00:02:46 +07:00
bflatastic 7614567502 Merge main into #872 2026-10-20 00:02:45 +07:00
bflatastic f2ede63dd9 #867: comments describe the line separator without naming another product 2026-10-20 00:00:09 +07:00
bflatastic d087645a00 #812: Polish in docs/translating.md and the plural comment 2026-10-20 00:00:03 +07:00
bflatastic 75784f24d6 #812: Polish for the UI strings and commands main added since 2026-10-20 00:00:00 +07:00
bflatastic 08c3f8f944 #812: mark Polish as a machine draft (status line, neutral header) 2026-10-19 23:59:59 +07:00
bflatastic bb1fd20114 Merge main into #812 2026-10-19 23:59:47 +07:00
BFlat ba496eddca Merge pull request #811 from MortenHusted/fix/bound-paint-edits-detach
M3: an explicit paint edit detaches the variable bound to what it changed
2026-10-19 23:59:18 +07:00
BFlat ec512a7767 Merge pull request #807 from MortenHusted/fix/wrap-error-command
M1: wrap errors name the command that was run
2026-10-19 23:58:39 +07:00
bflatastic 742e911652 #832: the per-side fields are two plain rows like Individual corners (toggle in the first); shut H/V fields read Mixed when their sides disagree; no comment naming the incumbent 2026-10-19 23:58:26 +07:00
bflatastic 99a8ef0e26 #832: drop the progress-doc edits (ROADMAP header, log and revision rows, auto-layout-parity row); the maintainer adds them 2026-10-19 23:55:26 +07:00
bflatastic 1fdd95070e Merge main into #832 2026-10-19 23:54:37 +07:00
bflatastic bb793470f1 #854: no reorder ring on a layer under 16 px on screen, and its hit radius is at most a quarter of the layer, so a small layer still drags the selection 2026-10-19 23:49:03 +07:00
bflatastic 109b90c0aa #854: locale entries in sorted position 2026-10-19 23:49:02 +07:00
bflatastic cfbb5a8f50 #854: no help-centre URL in selection_reorder.rs 2026-10-19 23:49:01 +07:00
bflatastic 9fddc4b058 #854: drop the progress-doc edits (ROADMAP, gaps, parity docs, generated checklist); the maintainer adds them 2026-10-19 23:49:00 +07:00
bflatastic fb0e25a258 Merge #865 into #854
# Conflicts:
#	crates/ui-egui/src/selection_spacing.rs
2026-10-19 23:44:17 +07:00
bflatastic 6325a323ee #821: move the loadFont/rescanFonts test into fonts_tests 2026-10-19 23:44:08 +07:00
bflatastic 7126deaed0 #821: es and fi labels for text.rescanFonts, text.loadFont, text.refit 2026-10-19 23:44:08 +07:00
bflatastic 203ec13e03 #821: text.loadFont reads at most MAX_FONT_FILE (the font scan's 256 MiB cap) 2026-10-19 23:44:08 +07:00
bflatastic 4cc2230013 #861: one search field for Pages and Layers; Escape closes it only from the field and goes no further; the search button shows when it's on; filter pages in the row loop 2026-10-19 23:43:38 +07:00
bflatastic b8aa1ef8f0 Merge main into #821 2026-10-19 23:42:20 +07:00
bflatastic 17da39a5ea Merge main into #870 2026-10-19 23:42:15 +07:00
bflatastic 95483273e3 Merge main into #862 2026-10-19 23:42:13 +07:00
bflatastic 163c293b8c Merge main into #847 2026-10-19 23:42:11 +07:00
叶林橦 cecc2865b0 Layout grids: a stretched grid's count takes no Auto
CodeRabbit: typing "Auto" in the count of a stretched grid set 0, which the setter clamped to 1. The count field only takes the word where 0 is Auto (a fixed-size grid); a stretched grid's count is a plain number.
2026-10-20 00:39:29 +08:00
BFlat 44a504ddcf Merge pull request #883 from storytold/community/batch-20
Community batch 20: automation roots for the CLI and links, web file opening, CI bump (4 PRs)
2026-10-19 23:38:34 +07:00
bflatastic be38c68ac5 Merge #865 into #861
# Conflicts:
#	crates/ui-egui/src/layers.rs
2026-10-19 23:38:25 +07:00
bflatastic 113d7ec106 #831: overflow menu lists open files with the shared menu rows (active checked); drop the falling close animation; update tab motion in place instead of cloning it every frame 2026-10-19 23:37:27 +07:00
YarilaFox afa431b47a M7: credit Archivo font in attribution 2026-10-19 21:26:49 +05:00
叶林橦 8450d18c8e Layout grids: Auto width takes the word only where 0 is Auto
Review fixes for #881 (FredApps, CodeRabbit).

- number_field: the word for Auto (the placeholder's own text in the UI's
  language, or "auto") is taken only by a field that asks for it (`Empty::Auto`),
  not by every field whose placeholder reads "Auto". An auto layout's gap
  ("Auto" is Space between) and a prototype overlay's offset ("Auto" is no
  offset) keep their own meaning of 0; the fixed "自动" match is gone.
- layout: Auto width has no hidden 60 px band — the bands step by the gutter
  alone, so the pitch is what the panel shows. A zero-width band snaps to one
  line, not two identical edges.
- right: the width field shows "Auto" (not 0) when there is no width, and a
  stretched grid shows it dim and not editable (its bands divide the frame),
  with the margin and gutter still editable.
- tests: the field suite checks the word on a grid's width (0) and on an auto
  layout's gap (nothing); the layout suite pins the gutter pitch, the Auto
  count and a stretched grid's bands.
- docs: ROADMAP and ui-parity state the pitch and what changes for saved grids.
2026-10-20 00:13:37 +08:00
YarilaFox 5a4b7c2795 M7: make brand accents legible and fix home tests 2026-10-19 20:51:47 +05:00
bflatastic e1da3f36bb Merge fix/830 2026-10-19 22:49:46 +07:00
bflatastic 94291f13e8 Merge fix/841 2026-10-19 22:49:45 +07:00
bflatastic 624795ba38 Merge fix/839 2026-10-19 22:49:45 +07:00
bflatastic 3d109bae1e Merge fix/819 2026-10-19 22:49:44 +07:00
bflatastic 80c8c7fa87 #830: "Couldn't read {name}." in every full catalog 2026-10-19 22:39:25 +07:00
bflatastic b744ff3e14 #830: cap files read in the browser at 512 MiB, and say why a file isn't opened 2026-10-19 22:39:23 +07:00
bflatastic bafc814712 #830: drop the progress-doc edit (docs/hardware-parity.md) 2026-10-19 22:22:32 +07:00
bflatastic 5738ce673c #839: mcp reuses roots_of for its automation roots 2026-10-19 22:20:37 +07:00
bflatastic 0008fc7901 #878: move the parse_color test into live_tests 2026-10-19 22:18:15 +07:00
bflatastic 479313bf67 Merge main into #830 2026-10-19 22:13:15 +07:00
bflatastic 07eb207d7c Merge main into #839 2026-10-19 22:13:04 +07:00
bflatastic 25899735a8 Merge main into #819 2026-10-19 22:12:58 +07:00
bflatastic 09427e72be #871: text style shortcuts and text.set detach the bound field they change 2026-10-19 22:09:05 +07:00
bflatastic 2f14eeda60 #871: leave progress docs to the maintainer 2026-10-19 22:09:04 +07:00
bflatastic 46ce6102d8 Merge main into #829 2026-10-19 22:08:02 +07:00
bflatastic 1e1b59280d Merge main into #849 2026-10-19 22:07:53 +07:00
bflatastic 918a28a4f4 Merge main into #848 2026-10-19 22:07:51 +07:00
bflatastic 97cd06fd14 Merge main into #842 2026-10-19 22:07:50 +07:00
bflatastic ee99a89e08 Merge main into #817 2026-10-19 22:07:49 +07:00
bflatastic 9c7b315301 Merge main into #814 2026-10-19 22:07:48 +07:00
bflatastic b55f009198 Merge main into #811 2026-10-19 22:07:45 +07:00
bflatastic ca3832c0d9 Merge main into #807 2026-10-19 22:07:44 +07:00
叶林橦 3e02bffbbc Layout grids: Auto line width for columns and rows
Columns and rows can now set their line width to Auto: a width of 0 (or
typing the word "Auto" / "自动" in the field) draws a one-pixel hairline
down the band's middle, like the square grid, instead of a filled band.

- grid_lines: a non-stretched grid with size <= 0 emits a hairline at each
  band's middle rather than a zero/negative-width rect.
- canvas: a degenerate (zero-width or zero-height) grid rect is painted as a
  1px line; a filled band is still painted as a rectangle.
- number_field: a field whose placeholder is "Auto" also accepts the word
  "Auto"/"自动", mapping it to 0.
- right: the grid size field shows "Auto" and the align menu no longer
  forces a 60px band on a zero width.
- props: document the Auto semantics of LayoutGrid::size.
- ROADMAP.md / docs/ui-parity.md: note the change.
- layout tests: regression test for the Auto hairline.
2026-10-19 22:51:57 +08:00
YarilaFox 4b12a82839 M7: refresh FigCraft brand colors and wordmark 2026-10-19 19:43:58 +05:00
NetterSilverandClaude Haiku 5.5 258d6f61c0 Reject non-hex characters in live::parse_color
Found while testing crates/engine/src/live.rs:85

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 14:49:19 +02:00
NetterSilverandClaude Haiku 5.5 df42e380f2 Reject non-hex characters inside invite link percent escapes
Found while testing crates/collab-net/src/invite.rs:242 (percent_decode)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 12:37:21 +00:00
Davit MatchakhelidzeandClaude Opus 5.5 d2367d1eac Web: offer Georgian only when Noto Sans Georgian is built in; ROADMAP lists the Georgian draft
Without craft-fonts the web build has no Georgian face and can't read installed fonts, so the
language picker and System must skip Georgian there, as they already skip Japanese, Korean and
Chinese without their faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-19 16:35:09 +04:00
NetterSilverandClaude Haiku 5.5 ccddaa74cd Clamp overflowing colour channels to 1 instead of 0
Found while testing crates/color/src/lib.rs:63 (clamp01, used by Color::new via parse_css at :155-166)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 12:27:23 +00:00
justinjohn0306andClaude Opus 5.5 82a50dad1b M7: a preference to start on the Home screen (#857)
Launching opens a new empty file, which then has to be closed to get
back to Home. Preferences ▸ "Start on the Home screen"
(`pref.startOnHome {on?}`, off by default) starts the desktop app on Home
instead. Files named on the command line and recovered files still open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:53:24 +05:30
justinjohn0306andClaude Opus 5.5 6a163721dc M3: explicit edits of bound scalar fields detach their variable (#809)
An explicit edit of a bound width, height, opacity, gap, padding, corner
radius, stroke weight, arc or text field reported ok, but normalize wrote
the variable's value straight back. Now the edit sticks and that field's
binding is dropped, as paint edits already do.

- `styles::bound_before` / `detach_edited`: compare each layer-level
  bound field's stored value before and after the edit (`bound_field`),
  so keys written unchanged keep their bindings and fields moved along
  with another (proportions, min/max) lose theirs. A binding the edit
  itself made (`boundVariables`) stays. List and component property
  bindings are left to their own rules.
- Hooked into node.set, transform.resize, object.opacityKeys, text.style
  and `Session::preview` (canvas drags of the selected layers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:33:29 +05:30
Davit MatchakhelidzeandClaude Opus 5.5 41c123657e i18n: Georgian (ka) interface language
Adds crates/ui-egui/locales/ka.toml (ქართული): every key of en.toml (1,730 UI
strings), every command label (567) and every menu wording (31), marked as a
draft. Georgian has no letter case and keeps a noun singular after a numeral,
so plural entries give the same text for one and other. System picks it for
ka, ka-GE and ka_GE.UTF-8; pref.language's help text lists ka.

Inter and egui's fonts have no Georgian, so the UI draws it in craft-fonts'
Noto Sans Georgian: it joins the built-in faces in theme::install_fonts and
the web build keeps it (crates/text/build.rs). craft-fonts-ref moves to
a71c20e3 (storytold/craft-fonts#27, the commit that adds the face) in the
release and FreeBSD workflows, and the release builds the web version with
craft-fonts (web-craft-fonts: true).

Tests: the Georgian locale tags, draft status and count-neutral plurals, and
that the built-in Georgian face draws every Georgian letter in the catalog
(skipped without craft-fonts). Docs: localization-parity, translating, mcp,
README and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-19 15:53:03 +04:00
MortenHustedandCodex 0108a7c2f5 M3: copied components paste as instances within their file
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-19 13:34:40 +02:00
NetterSilverandClaude Haiku 5.5 b7c9c712b1 Reject server addresses whose port is not all ASCII digits
Found while testing crates/collab-net/src/invite.rs:valid_host (around line 205)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 11:21:34 +00:00
timrrrr 1ca3a5ca2d M1: merge main and resolve crop progress conflicts 2026-10-19 13:54:10 +03:00
AganzeFelicite 710c95e527 test(text): a selection across a line separator shows the break (#852) 2026-10-19 12:52:09 +02:00
AganzeFelicite 6e7b63bc16 fix(pages): Delete that removes an interaction ends the Pages list focus 2026-10-19 12:50:43 +02:00
AganzeFelicite 3e83ae44b7 fix(pages): a prototype gesture on the canvas ends the Pages list focus 2026-10-19 12:50:43 +02:00
dependabot[bot] 40d7ce14ce ci(deps): bump actions/checkout
Bumps the actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-19 10:18:38 +00:00
AganzeFelicite b930ea5871 test(text): the caret's line on either side of a line separator (#852) 2026-10-19 12:13:12 +02:00
AganzeFelicite 1b66fec3ec fix(text): the caret after a line break sits on the next line
`caret` put the index right after a line break (a newline, or a line
separator) at the end of the line above, so after Enter the caret showed
on the previous line until something was typed, and Up/Down counted
lines from there. The index before the break stays at the end of its
line; the one after it is now on the next line, as after a wrap.
Selection highlights already started such lines at their own start
(#665); their comment no longer describes the old caret.
2026-10-19 12:12:54 +02:00
AganzeFelicite e17fccd7ab fix(pages): a second Delete after deleting layers keeps the page
After a click in the Pages list, layers selected since (Select all, say)
took the first Delete, which left nothing selected, so a second Delete
deleted the page. Delete that doesn't take the page now ends the Pages
list's hold on the keyboard.
2026-10-19 11:50:07 +02:00
AganzeFelicite 9f895a37d5 fix(pages): Delete deletes no page once the Pages list is off screen
The Pages list kept the keyboard after a switch to the Assets tab or to
another open file, so Delete with nothing selected deleted a page there.
It now deletes a page only while the list that was clicked is on screen
(File tab, list expanded, panel and interface shown) and its file is the
one open.
2026-10-19 11:48:35 +02:00
AganzeFelicite 7af44512e7 refactor(pages): remember which file the Pages list focus belongs to 2026-10-19 11:46:47 +02:00
AganzeFelicite 9e4dcc9204 test(text): line separators in layout, lists and carets (#852) 2026-10-19 11:34:58 +02:00
AganzeFelicite 692f45123c fix(text): a line separator breaks the line inside its paragraph (#852)
Figma stores Shift+Enter as U+2028. `.fig` imports keep it, but layout
only broke lines at a newline, so imported text ran soft-broken lines
together ("Option 1SL: …"), lost the empty line of a separator followed
by a newline, and put glyph bullets on one line.

A line separator now ends its line without ending the paragraph: no
paragraph spacing and no new list marker after it, the next line keeps
the item's indent, and it draws no glyph. A trailing one leaves an empty
last line, and a line it cuts short isn't stretched when justified.
2026-10-19 11:34:47 +02:00
AganzeFelicite 855bbb328f refactor(text): Line::break_after for carets and selections
Carets, clicks and selections asked `para_end && !last_line` to mean
"the line ends with a break char". Record that on the line instead, so a
break that doesn't end the paragraph can use the same rules. No
behaviour change: today only a newline ends a line early.
2026-10-19 11:34:34 +02:00
AganzeFelicite fd02772d22 refactor(text): one helper for the chars that end a line 2026-10-19 11:34:21 +02:00
YarilaFox 2af54a26fb M7: keep tab strip usable with many files 2026-10-19 14:23:04 +05:00
YarilaFox d2dfe2ef20 M7: preserve active tab and support tab reordering 2026-10-19 14:21:52 +05:00
AganzeFelicite 139e0ba308 chore(contributors): add AganzeFelicite to people.toml 2026-10-19 10:53:34 +02:00
AganzeFelicite 5d40b3f42f fix(pages): Delete deletes the page picked in the Pages list (#859)
Clicking a page row (or the add-page button) gives the Pages list the
keyboard: Delete or Backspace with no layers selected now deletes the
current page instead of failing with "nothing is selected". No
confirmation; a "Page deleted" toast says so and Undo brings it back.
The context menu's Delete page shows the same toast. Selecting layers,
clicking a layer row or pressing on the canvas gives Delete back to the
layers.
2026-10-19 10:53:34 +02:00
slyimandClaude Opus 5.5 be212f00e6 #826: the stroke-width test covers the Frame and Text marks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:25:34 +03:00
MortenHustedandClaude Opus 5.5 be9e72f74d M3: instances follow the variable modes their main component sets
An instance copied its main's variable modes when it was made and kept
the copy as modes of its own. Switching it to another variant kept the
old variant's modes, and a mode set on the main later never reached it.

Instances now take the modes their main sets through the link
(`Document::inherited_modes`), closer than any frame around them. Modes
set on the instance itself still win and stay through variant switches.
Bindings are re-applied for the instances of a main that chose another
mode and for an instance that switched mains. Detaching keeps the modes
the instance took from its main, and the Variable modes panel shows them
as the instance's Auto value.

Instances made before this change keep their copy as modes of their
own; setting one back to Auto lets it follow its main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:15:34 +02:00
AJ Richardson 3b7b28c03c improve page search functionality to close with Escape key even when unfocused 2026-10-19 16:37:52 +10:00
AJ Richardson caf0f44d90 #855: The Search button for the pages doesn't work as expected 2026-10-19 16:08:26 +10:00
Cody Campbell 4e4bf54bb6 M1: smart-selection drag reordering 2026-10-18 18:53:32 -04:00
MortenHustedandClaude Opus 5.5 6f90717e71 M3: effects refuse keys they don't read instead of dropping them
node.set refuses unknown properties, but the effects inside it were read
by serde, which drops keys it doesn't know. A drop shadow given
offsetX/offsetY (the names variable bindings use for its offset) was
stored with the default offset [0, 4] and the call said ok. effect.add
and effect.set dropped them the same way.

The three now refuse what the effect doesn't read and say what it
takes: "effects[0]: unknown properties: offsetX, offsetY (a dropShadow
takes blend, color, offset, radius, showBehind, spread, type, visible)".
A key counts as read when setting it to null makes the effect fail to
parse or come out different, so serde's aliases count as read without a
list of them here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:05 +02:00
MortenHustedandClaude Opus 5.5 147a374350 M3: a refused slot names component.makeSlot
component.makeSlot adds its slot property through add_prop, which named
itself in every error: making a second slot called "Content" said
"invalid parameters for `component.addProperty`: a property named
`Content` already exists", a command the caller never ran. add_prop now
takes the command errors name, the fix #807 makes for wrap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 017517fbb9 #847: an instance's values are kept under the original definition
An assignment and the layers that read it can name different copies of
one definition (the set's own id on one side, a variant's copy on the
other). The values were kept under the id the assignment names and read
under the id the layer names, so such a value never reached its layer.
Both sides now go through prop_root, the definition the copies were
made from (CodeRabbit on #847). In Untitled UI, 143 assignments name a
copy that no layer reads while another copy of it is read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 f97789ade1 M7: CSS borders follow individual stroke sides
A layer with individual stroke sides (a header with only a bottom divider,
strokeSides [0, 0, 1, 0]) came out of the CSS, HTML, React and Leptos
code as `border: 1px solid ...` on all four sides, because the border
was written from the stroke weight alone. With individual sides it is now
a border-top/right/bottom/left for each side that has a weight, and a
side bound to a variable names it (border-top: var(--border-top, 3px)).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 c530b4067d M3: .fig imports read property values written only as variables
A component property assignment carries its value in `value`, and in
current files also in the variable form `varValue`. Many assignments
leave `value` empty and say it only in `varValue` (booleans as
boolValue, instance swaps as symbolIdValue, text as textDataValue), and
those were ignored: a hidden layer showed and a swapped icon stayed the
default. In Untitled UI that is 1,643 booleans and 185 swaps, so inputs
showed their hint text, help icon and leading icon on top of the
neighbouring layers. Both forms are read now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 544b2455c9 M8: a path in a root reached through a link is told why it is refused
When a path can't be resolved (a link that leads nowhere, `..` below a
missing folder), the reason is given only for paths inside a root.
That check compared the path as typed with the roots resolved, so a
root reached through a link (`/tmp/work` on macOS is
`/private/tmp/work`) called its own dangling links "outside the folders
that may be written: /private/tmp/work", and a link out of a root still
had its reason told. The existing part of the path is now resolved
before the comparison, as the roots were.

This is also why `links_that_lead_out_of_a_root_are_outside` and
`outside_paths_are_refused_alike_whatever_exists_there` failed on
macOS, where the temp folder is under `/var` -> `/private/var`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:03 +02:00
MortenHustedandClaude Opus 5.5 4a54aa733f M3: .fig imports keep the properties variant layers are bound to
Each variant of a component set keeps a copy of the set's property
definitions (an id and a parentPropDefId, no name or type), and the
variant's layers and its instances' assignments use the copy's id. Those
copies were skipped, so imported variants lost every boolean, text and
instance swap binding, and instances lost their property values. In
Untitled UI (8,074 copies) an input's "Hint text" no longer followed its
property. A copy now names the definition it was made from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:03 +02:00
Muamer MustaficandCursor 63dc418cc2 M7: a web file picker closed without a cancel event settles on refocus (#759)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:50:27 +02:00
Al Rifat Sabbir 847e610550 Add Bengali script support in build.rs 2026-10-19 03:46:01 +06:00
Muamer MustaficandCursor 79ac16a256 M3: doc comments for the structure commands and binding re-apply (#810)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:40:35 +02:00
Muamer MustaficandCursor 85c5286d39 M1: doc comments for the menu invoke and SVG clipboard export (#808)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:40:33 +02:00
Al Rifat Sabbir 3878f89bd6 Change order of characters in language script test 2026-10-19 03:27:24 +06:00
skylar taylor-barrick a0d1162976 Desktop: refuse unknown command-line options instead of ignoring them (#836) (#844)
A mistyped or unsupported flag (e.g. --automation-write-roots) used to be
dropped silently, so the app started unconfined. It now exits 2 with
'unknown option', like a bad automation root. A bare -- ends the options.

Adds a desktop control-channel test that file.save / file.saveAs outside the
write root are refused and create nothing, while saves inside succeed, and
documents that the root flags arrived in 0.7.0 (0.6.0 ignores them).
2026-10-18 15:20:15 -06:00
NetterSilverandClaude Haiku 5.5 23d0d9e49e Accept --control=PORT like --control PORT
Fixes #837

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 21:14:08 +00:00
Al Rifat Sabbir 0a3f2341a8 Add support for Bengali text in the UI
Updated cjk.rs
2026-10-19 03:10:55 +06:00
Al Rifat Sabbir 98a1561183 Update supported languages in translating.md
Added Bengali to the list of supported languages and clarified the status of Russian and Turkish translations.
2026-10-19 03:04:00 +06:00
Hamza 35cc685e90 cli: an unconfined write still follows a symbolic link
Routing the CLI's writes through `file_access` changed what happens when the
output path is a symbolic link. `file_access::write` stages the bytes and
renames them into place, and a rename replaces the link with a regular file,
where `std::fs::write` followed it and wrote to its target. A render to a link
reported success while the target kept its old contents.

Unconfined writes go back to `std::fs::write`. Confined ones keep the staged
write, which is right there: `Roots::check` resolves the path first and has
already refused a link leading out of the root, so the write lands on the
resolved target inside it.
2026-10-19 00:52:40 +05:00
NetterSilverandClaude Haiku 5.5 39f30d63c8 Refuse empty mode lists and blank names in variable.createCollection
Found while testing crates/engine/src/cmd/styles.rs:119-124

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 21:27:31 +02:00
Dylan M. Taylor 7fcb193f2a ci: configure Dependabot for Cargo and GitHub Actions (#825)
* ci: configure Dependabot for Cargo and GitHub Actions

* ci: add Cargo update entry for /plugins workspace in Dependabot

* ci: consolidate Cargo Dependabot directories in figcraft
2026-10-18 15:10:42 -04:00
Hamza c3cb8c3e0e cli: confine info, export, convert, render and run to automation roots
`figcraft-cli mcp` takes `--automation-read-root` / `--automation-write-root`
to confine the files an agent's commands touch, but the file subcommands took
neither: `figcraft-cli run script.json --out /anywhere/x.figcraft` read and
wrote as the user who started it. The README invites agent use, and a script
or agent driving `run`, `export` or `convert` had no way to say which folders
it may reach (#833).

`info`, `export`, `convert`, `render` and `run` take the same two flags, with
the same semantics: repeatable, the folders must exist, reads inside a read or
write root, writes only inside a write root, everything else refused -- so
giving no write root denies writing altogether. Without the flags they behave
as before.

The flags would have been decorative on their own: `figcraft_engine` enforces
roots through `file_access`, which the engine's own loads already go through,
but the CLI wrote its output with `std::fs` and read its script the same way.
Those three calls now go through `file_access` too, so the confinement reaches
the files the CLI itself touches.
2026-10-18 23:48:54 +05:00
Filip Strajnar eae9546878 M6: export as Android Vector Drawable (XML)
ExportFormat::VectorDrawable ("XML", .xml) and ExportSetting.vd_compat
(bestEffort / exact / ignore, stored only when not the default) wire the
figcraft-vectordrawable writer into file.export, file.exportLayers and
export.add/update: a vdCompat param (junk refused), svgIdAttribute writes
android:name, and the results carry the writer's warnings (per file for
export layers). The Export section lists XML with an "Android Vector
Drawable" tooltip and its id attribute and compatibility options, File >
Export... gets an XML preset, and the toasts say when content was changed
or left out. CLI: --format xml / .xml outputs, --vd-compat, warnings as
note: lines. MCP: format xml returns the XML as text plus its warnings.
A .fig export writes XML settings as SVG with a note.

Docs: file-format-parity (row and revision), ui-parity and roadmap
revisions, architecture (the crate), cli and mcp.
2026-10-18 20:39:46 +02:00
Filip Strajnar 8b5441eb84 M6: figcraft-vectordrawable: an Android Vector Drawable writer
A new L4 crate that writes layers as an Android Vector Drawable (XML):
shapes, booleans and text as baked path data, solid and gradient fills
(linear, radial and sweep, exact for any handle frame via a group and a
2x2 SVD), native strokes where Android draws them the same and filled
stroke outlines otherwise, clip frames and masks as <clip-path> groups.

What a drawable can't hold is governed by Compat (bestEffort / exact /
ignore): layer opacity over overlapping shapes, even-odd fills and clips
(rewritten to non-zero via pathops), diamond gradients, alpha masks;
effects, image and pattern fills and blend modes are left out with a
warning. Output is pretty-printed, numbers are finite and short, names
(android:name) are optional, escaped and unique.
2026-10-18 20:24:19 +02:00
Ahmed5Emad 02e8f644fd M2: doc comments for the padding diff's functions 2026-10-18 21:02:22 +03:00
slyimandClaude Opus 5.5 54458cfb20 #826: tests for the inset Frame and Text marks and the connected Slice knife
frame_and_text_marks_are_inset fails if either mark reaches the box edge
again; slice_is_one_connected_knife fails if the handle stops short of the
blade's base or the icon goes back to separate marks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:52:39 +03:00
Muamer MustaficandCursor 309bac6185 M7: the web build opens files: File ▸ Open, Import and dropped files (#759)
File ▸ Open, Import and Place image did nothing in the browser (the pickers
return a path, which a browser never has) and dropped files were ignored
(eframe hands their contents over asynchronously).

- Services::pick_open_contents: a platform can pick a file's name and
  contents; the web build uses an <input type="file">. Open and Import pass
  them to file.open / file.import as data + name.
- A dropped file without a path waits behind the opening card until its
  contents are read (bytes_async on wasm), or says it couldn't be read.
- Canvas drops still import, as on the desktop.

Closes #759.

Co-authored-by: Cursor <[email protected]>
2026-10-18 19:32:46 +02:00
NetterSilverandClaude Haiku 5.5 72990373c4 Keep multiline layer names inside the SwiftUI/Compose too-deep comment
Found while testing crates/engine/src/codegen.rs:865 (swift_layer) and :1038 (compose_layer)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 19:27:45 +02:00
Ahmed5Emad aec3a22fdf M2: shut padding pair fields edit their own axis
The closed Spacing row paired each icon with the other axis: the
side-bars field wrote top/bottom and the top/bottom-bars field wrote
left/right. The H field now binds paddingHorizontal (left+right) and the
V field paddingVertical (top+bottom); the PaddingH/PaddingV paintings
were swapped to match so every icon is pixel-identical.
(shut_pair_fields_edit_their_own_axis).
2026-10-18 20:24:12 +03:00
Mateus Villain 3faafe1bee contributors: credit mateusvillain by GitHub profile name 2026-10-18 14:13:56 -03:00
slyimandClaude Opus 5.5 7a5bd57611 #826: the Slice handle overlaps the blade's base so the knife joins at every size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:11:38 +03:00
slyimandClaude Opus 5.5 7558ac0444 #826: doc comments for draw and main_toolbar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:06:21 +03:00
Mateus Villain a102b726f5 M1: picker keeps typed LCH/OKLCH values; CSS hue units
From review: the three-field models keep the values last typed while the
colour is still theirs, so a chroma lowered to fit sRGB or a hue typed on
a grey isn't lost on the next frame. One table per model (Polar) with its
own field ids. lch()/oklch() read deg, rad, grad, turn and none through
one helper, with a junk sweep. lch.rs reuses the sRGB transfer functions
from profile.rs.
2026-10-18 14:03:46 -03:00
Ahmed5Emad bc10bb190d M2: individual padding in the Layout panel
The Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a
2x2 grid in place of the H/V fields, each bound to its own padding key with
one undo step; shut H/V fields show both values ("16,54") when the sides
disagree instead of "Mixed". Original code-drawn side icons, English and
Spanish strings (padding_individual_tests).
2026-10-18 20:03:43 +03:00
slyimandClaude Opus 5.5 f03fc476c8 M4: toolbar marks share one size and stroke weight; a simpler Slice icon (#737, #738, #739)
Every mark in the bottom toolbar is now painted in one 18-point box. The
mode switcher drew its marks in a 14-point box, so Draw, Prototype and
Inspect read smaller than the tools. The flyout carets move from a
10-point box, whose stroke rounded to one physical pixel thinner than the
row's, to a 14-point box that rounds to the same width at 1x, 1.5x and 2x.

Frame and Text are inset by a unit: their long pixel-hinted strokes read
heavier than the anti-aliased outlines beside them. Slice is a knife (a
slim blade and a solid handle), one silhouette instead of a box crossed
by a diagonal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:59:56 +03:00
Attila Valentin NémethandClaude Opus 5.5 33c0f94cc8 M4: font review fixes: new styles of loaded families, ids checks, repeat loads
- text.rescanFonts offers a loaded family the files it hasn't read (styles
  installed since) and refits every family that gained faces; `added` still
  lists only the families that were unknown
- text.refit refuses an `ids` that isn't a list instead of refitting the
  whole document
- text.loadFont with a file already loaded returns its families without
  keeping its bytes or adding its faces again
- doc comments on the functions this change touches

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:46:20 +02:00
focuerandClaude Opus 5.5 71dfae9442 web: rustfmt window.figcraft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:14:43 +09:00
focuerandClaude Opus 5.5 987ad67f7e web: window.figcraft, the control channel for the page
The same JSON methods as the desktop control server (engine.execute, ui.inspect, ui.screenshot, …),
as promises on window.figcraft, like FilmCraft's window.filmcraft: an agent driving the editor in a
browser tab or an <iframe> uses the same commands as the CLI and the MCP server. openFile opens
bytes the way a dropped file opens; captureDownloads keeps saves in the page for readFile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:00:56 +09:00
Attila Valentin NémethandClaude Opus 5.5 ba4c502ada M4: text.refit: auto-sizing text measures its box again once its font is there
A text box keeps the size it was measured at, so text laid out while its
family was missing kept the fallback font's box after the font arrived (a
hugging chip clipped its label).

- text.refit {ids?} measures auto-sizing text again (whole document by
  default) → {refitted}
- text.rescanFonts and text.loadFont refit the open document's text in the
  families they add, as one undo step

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:35:31 +02:00
Attila Valentin NémethandClaude Opus 5.5 972e228f41 M4: text.rescanFonts and text.loadFont: fonts installed while the app runs
The system fonts are indexed once per process, so a font installed while
FigCraft (or `figcraft-cli mcp`) runs stayed missing until a restart.

- text.rescanFonts walks the font folders again and offers the families
  installed since (loaded families keep their faces) → {added}
- text.loadFont {path | data} adds a font file without installing it → {families}
- both clear the layout cache so text lays out again in the new face

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:13:49 +02:00
DubblbindongandClaude Opus 5.5 a370211b48 fix(plugin): keep the plugin directory's wire format; refuse oversized save_file data
- PluginEntry no longer carries save_file: postcard is positional and existing
  messages keep their fields (docs/collab-protocol.md), so mixed app/server
  versions keep reading the directory. Manage plugins still shows "can save files".
- save_file checks the data length before reading it and refuses with status 1
  and the reason, as documented, instead of failing the run with a limit error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:11:59 +02:00
Dubblbindong 38034a4c4a Merge main into saveFile branch
Resolve README (four example plugins) and the de/en/es catalogs (keep both sides, sorted).
2026-10-18 16:50:01 +02:00
Mateus Villain 9834f77db7 M1: LCH and OKLCH models in the colour picker
The picker's model menu offers LCH and OKLCH beside Hex, RGB, HSL, HSB
and CSS, on desktop and web. Lightness, chroma and hue fields; an edit
outside sRGB lowers chroma and keeps lightness and hue (CSS gamut
mapping). The CSS field and Color::parse_css read lch() and oklch().
2026-10-18 11:38:24 -03:00
Muamer MustaficandCursor b778ba17cd M1: copying text layers puts their text on the clipboard (#808)
edit.copy and edit.cut mirror a selection made only of text layers to the system clipboard as plain text, one layer per line, instead of SVG. Other selections stay SVG; pasting back into FigCraft still pastes the copied layers.

Closes #808

Co-authored-by: Cursor <[email protected]>
2026-10-18 16:08:12 +02:00
Al Rifat Sabbir bbf0d62863 Update Bengali translations in bn.toml
Reviewed and updated bugs.
2026-10-18 19:43:17 +06:00
Muamer MustaficandCursor 4506f49ad4 M3: removing auto layout drops its gap and padding bindings (#810)
layout.remove and node.set {layout: null|"none"} now drop the bindings of auto layout fields (gap, crossGap, padding...), as frame-to-section already did for some of them (it now drops all of them). Re-applying a binding no longer creates an auto layout on a frame without one: the binding waits for the next auto layout.

Closes #810

Co-authored-by: Cursor <[email protected]>
2026-10-18 15:21:28 +02:00
Al Rifat Sabbir a3e9af917d Add Bengali UI Translation
Add Bengali translation (bn.toml)
2026-10-18 19:08:34 +06:00
LucasandAntigravity 32bc822451 fix(i18n): Restrict Polish plural One to n == 1 per CLDR rules
Polish integer plural rules use Plural::One strictly for n == 1, while
higher counts ending in 1 (e.g. 21, 101) use Plural::Many.
Also adds unit tests for Polish integer plural coverage.

Co-Authored-By: Antigravity <[email protected]>
2026-10-18 14:45:19 +02:00
leandrodslvandClaude Sonnet 5.5 4d9d12ea13 fix(ui): let escape close an open menu before its dialog
Escape closed the whole Preferences dialog even while a menu was open over
it. That held while Preferences had no menu of its own; the Interface size
menu is the first, so one Escape threw away the dialog with the menu.
Dialog keys now leave Escape to an open menu, and the next Escape closes the
dialog as before.

Adds a keyboard test of the Interface size menu: Tab reaches it, Enter opens
it, the arrows move through the sizes, Enter picks one and closes the menu,
and Escape closes only the menu.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:39:58 +02:00
leandrodslvandClaude Sonnet 5.5 a6659dcdfc test(ui): sweep every window size, system scale and interface size
Draws the interface the way a real window does (a fixed number of physical
pixels, its size in points being that divided by the scale drawn now) over
10 window sizes, 6 system scales and every interface size. Guards the rule a
fixed size promises: above 100 % the window still holds the smallest layout,
the panels leave room for a canvas, and the size settles without flicker.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:39:58 +02:00
leandrodslvandClaude Sonnet 5.5 2c3ea03c2e feat(ui): add the interface size preference (#540)
Preferences > Interface size and the `pref.uiScale` command make the whole
interface larger or smaller. Auto keeps the system's display scale; a fixed
size from 75 % to 300 % is absolute (100 % is one screen pixel per point on
every system). The size is held back while the window is too small for it.

Strings are added in en, es and fr; the control protocol, MCP, UI parity and
roadmap docs are updated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:39:57 +02:00
leandrodslvandClaude Sonnet 5 4223031c5c docs(contributors): credit leandrodslv by the name on their GitHub profile
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-18 14:37:56 +02:00
leandrodslvandClaude Sonnet 5 31d6566f3b i18n(fr): land the native-speaker wording fixes from the draft review
#783 completed the French catalog to every current string, superseding
the translations added in #681. This keeps only the ~50 UI strings and
8 command labels whose wording a native French speaker corrected during
that review (keyboard focus, "Off" as "Désactivé", blend modes, stroke
join, spotlight, the no-break space before : ; ? !…), applied on top of
#783. The draft status is unchanged.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-18 14:37:56 +02:00
Dylan M. Taylorandbflatastic d8ff1441b1 docs: drop references to competing commercial software (#786)
* docs: drop references to competing commercial software

* docs: clarify parity checklist coverage in status callout

* #786: say interface design once in the tagline

---------

Co-authored-by: bflatastic <[email protected]>
2026-10-18 19:34:53 +07:00
LucasandAntigravity 9ae3223d22 feat(i18n): Complete Polish translation (pl)
Add full Polish localization for FigCraft UI, commands, and menus.
Includes Polish CLDR integer plural rules (one, few, many, other)
and eliminates all untranslated strings and borrowings.

Co-Authored-By: Antigravity <[email protected]>
2026-10-18 14:20:19 +02:00
MortenHustedandClaude Opus 5.5 d71f24741a #807: test the object.mask and vector.outlineStroke refusals too
Both reach wrap() (a page in a mask, a section outlined into a group)
and named `object.group` before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:18:25 +02:00
MortenHustedandClaude Opus 5.5 c7261cb907 M3: an explicit paint edit detaches the variable bound to what it changed
fill.set, stroke.set and node.set fill/fills/stroke/strokes reported ok
while normalize put the bound colour straight back, and node.set
{fills: []} brought the bound paint back. They now drop a paint's
colour, opacity or stop binding where the edit changed that value, as
selection.setPaint already did (its comparison moves to
lists::rebind_edited_paint, which also lets stop bindings follow a
removed stop). boundVariables applies last in props::apply, so a
binding given with the paints in one call stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:14:38 +02:00
MortenHusted d3327d79f3 Contributors: add my name for the About window 2026-10-18 13:58:13 +02:00
MortenHustedandClaude Opus 5.5 4707cb1579 M1: wrap errors name the command that was run
Grouping, framing, auto layout, masks, outline stroke and the boolean
commands share wrap(), which named `object.group` in every refusal
(`layout.add` on a page: "invalid parameters for `object.group`").
wrap() now takes the calling command's id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 13:58:13 +02:00
BFlat 018b8166b2 M3: a bound colour variable's alpha applies once, not squared (#802) (#804)
Fixes #802.

Fixes #802.
2026-10-18 18:50:19 +07:00
BFlat 86e91d0b71 Merge pull request #801 from phonedu/fix/silent-document-errors
M1: vector.create honours width/height by scaling the path
2026-10-18 18:37:38 +07:00
bflatastic d1dad2e1c0 Merge #801's latest commit (negative sizes: covered by the size check above)
# Conflicts:
#	crates/engine/src/cmd/create.rs
#	crates/engine/src/vector_behaviour_tests.rs
#	docs/mcp.md
2026-10-18 18:18:51 +07:00
phonedu fec902b7d5 M1: refuse negative vector sizes
A negative width or height was clamped to zero by the scale helper, producing a layer smaller than asked for; vector.create now says so. Regression test covers width -10 with height 8.
2026-10-18 19:00:25 +08:00
bflatastic 057bf9a289 #801: create_node and mcp.md say width/height scale a vector's path 2026-10-18 17:54:25 +07:00
bflatastic f1d336249a #801: vector.create accepts 0 on an axis the geometry has none of and refuses a flattening size; points scaling tests 2026-10-18 17:54:24 +07:00
bflatastic 55aa0165fd #801: keep a component set's outline as its real stroke (reverts the export change) 2026-10-18 17:41:11 +07:00
bflatastic 836d6f2fa6 Merge main into #801 2026-10-18 17:40:54 +07:00
BFlat 1c3e175f66 Merge pull request #798 from ashsajal1/m9-branch-merge-lists-item-by-item
M9: the other item lists merge one by item when a branch merges (gap 17)
2026-10-18 17:35:09 +07:00
phonedu aff3c4f18b M1: gate the outline migration on a marker and refuse impossible vector sizes
Review follow-up. A file written by this version carries editorOutlines, so a stroke someone chose is kept even when it matches the old decoration; only files without the marker get the one-time legacy cleanup. vector.create now errors when a requested side cannot scale the geometry or when both axes would collapse, instead of creating an invisible layer.
2026-10-18 18:22:48 +08:00
bflatastic adb23f0ff1 #798: branch.review docs name the lists that merge item by item 2026-10-18 17:01:03 +07:00
bflatastic 53c8308b66 #798: test layout grids, code links and interactions (add, change, delete, conflicts) 2026-10-18 17:01:03 +07:00
bflatastic a4c7e259ab #798: layout grids merge item by item, code links merge whole (keyed by language); fix stale doc comments 2026-10-18 17:01:02 +07:00
bflatastic de2915908c #798: drop progress-doc edits (maintainer-owned) 2026-10-18 17:01:02 +07:00
bflatastic 34b08ac8bf Merge main into #798 2026-10-18 16:57:23 +07:00
BFlat e9565813ac Merge pull request #800 from storytold/community/batch-18
Community batch 18: recovery lock wait (#334), Finnish catalog (2 PRs)
2026-10-18 16:51:37 +07:00
phonedu c87baa37cc docs: record the component set outline and vector size behaviour 2026-10-18 17:48:51 +08:00
phonedu 911831fb89 M1: vector.create scales its path to width/height
A path's intrinsic box replaced the width and height asked for, so an icon imported at 24 stayed on its source grid. Scale the geometry to the requested size; with one side the other keeps its proportion. A path or point set that draws nothing is an error now, not an invisible 0x0 layer.
2026-10-18 17:24:50 +08:00
phonedu be47f99534 M1: component sets keep the dashed canvas outline out of the document
Node::new gave every component set a purple dashed stroke, so export and screenshot drew the editor frame into the image. Sets now start with no paint; the canvas draws the dashed frame itself, and files written by earlier versions lose exactly that stored decoration when opened. A stroke someone chose is kept.
2026-10-18 17:24:49 +08:00
Mex 9f00703d8f Merge branch 'main' into fix/outline-stroke-rings 2026-10-18 11:21:59 +02:00
bflatastic 0b08ea1127 Merge fix/794 2026-10-18 16:21:45 +07:00
bflatastic 61ccfe18a8 Merge fix/795 2026-10-18 16:21:45 +07:00
bflatastic d1125130f6 #794: translate the slot-layers import note added on main 2026-10-18 16:14:45 +07:00
bflatastic ddd42b36bd #794: list Finnish with the other catalogs in translating.md instead of a status paragraph 2026-10-18 16:14:27 +07:00
bflatastic 6743c68203 #794: drop the Finnish-only tests that pin wordings (the generic catalog tests cover fi) 2026-10-18 16:14:27 +07:00
bflatastic caa3c5afce Merge main into #794 2026-10-18 16:13:48 +07:00
bflatastic 8d3a3e6171 #795: drop the hardware-parity revision row (test-only effect; the maintainer records it) 2026-10-18 16:13:41 +07:00
bflatastic d7a27c593e #795: wait once at startup for held recovery folders, only on Unix
Collect the folders whose lock is held, wait 100 ms once and retry only those,
instead of up to 100 ms per held folder. Recovery::start takes its folder without
waiting again (a held name already falls through to another). Windows doesn't
pass the lock to child processes, so the wait and its test are Unix-only; the
test holds the lock 10 ms against the 100 ms wait.
2026-10-18 16:13:34 +07:00
bflatastic 7d51d0515d Merge main into #795 2026-10-18 15:59:04 +07:00
Ashsajal 13fdae3d44 M9: the other item lists merge one by item when a branch merges (gap 17)
Fills, strokes and effects already merged item by item, so both sides
changing different items of one of those lists was no conflict. Every other
list conflicted as a whole: both sides editing different export settings, or
different guides, or different prototype interactions, or different
accessibility annotations or Dev Mode links, each had to be resolved by hand
for the whole list, though the items stand on their own.

Export settings, guides, prototype interactions, accessibility annotations and
the Dev Mode code links and resources now merge item by item, with the same
rule: the same item changed both ways, an item removed on one side, or
different items added on both sides still conflict.

Properties that are keyed maps rather than item lists (bound, varModes,
overrides, propRefs, pluginData) are deliberately left out: their items line up
by key, not by position. Text runs are left whole too: they live in the node's
text data, so merging them item by item means splitting a property the change
format keeps whole.

Replaces the test that documented the gap (other_lists_still_conflict_as_a_whole)
with one that checks each new list merges, and adds one for the same item
changed both ways still conflicting.
2026-10-18 14:55:12 +06:00
BFlat ef535e49b0 M1: REST import keeps slot layers and instances' slot content (#792) (#796)
Fixes #792.

Fixes #792.
2026-10-18 15:52:30 +07:00
Ashsajal e35f8eb1b7 M8: wait for a recovery folder lock that frees itself (#334)
A child process forked before its exec inherits the run's open descriptors,
and a flock lasts while any descriptor for the file is open. A crashed run
whose folder lock was still held by such a child read as a run that was still
going, so recover_all skipped it and its unsaved documents were not opened at
the next start. The desktop app's tests failed intermittently for the same
reason whenever another test spawned a process in parallel (the reduced-motion
read spawns gsettings on Linux and the BSDs): recovery_tests failed in 23 of
60 runs of the whole binary and never on its own.

Taking a folder's lock now retries try_lock briefly, so a lock that frees
itself is no longer read as a live run while a run that really is running
still keeps its folder.

Regression test: a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going
holds the crashed run's lock on a second descriptor, the way a forked child
does, and frees it shortly after; the copies come back. The recovery tests
that collide with a spawning test went from 8 failures in 10 runs to 0 in 10.
2026-10-18 14:15:56 +06:00
Esa Komulainen 55fbcd0d7e M10: add Finnish (fi) interface catalog 2026-10-18 11:03:36 +03:00
BFlat 8629a6f586 M3: variable.import reads variables outside meta and never imports nothing silently (#790) (#793)
Fixes #790.

Fixes #790.
2026-10-18 14:59:14 +07:00
BFlat 99e5db399b M7: quitting on macOS ends the process after saving, avoiding the Touch Bar teardown crash (#781) (#791)
- **Quit (macOS):** `on_exit` now calls `services::shutdown` and then, on macOS only,
  `std::process::exit(0)`. This happens before eframe tears the windows down, which is where the
  crash in #781 is thrown (same approach as the sibling apps, tracked in craft-libs#3).
- **`services::shutdown(app, dir, recovery)`** replaces `save_state`. It runs on every platform and
  everything in it is finished before it returns:
  1. the preferences (`state.json`) are written atomically, as before;
  2. this run's recovery copies are settled by the new `Recovery::quit(session, asked)`:
     - if the unsaved-changes question was asked (`ui.quit`: last window closed through the
       question, or `app.quit`), or nothing is unsaved, it works as before: `finish()` removes
       them;
     - otherwise the unsaved documents are copied (waiting for any copy still being written)
       and the copies stay for the next start, as after a crash. This is the macOS Quit menu
       (Cmd+Q): winit's menu calls `terminate:`, which reaches `on_exit` without asking. Until
       now that path removed the recovery copies of files that had unsaved changes;
  3. live sessions end explicitly: a session this app hosts tells everyone and stops; a joined
     session is left. Before, this only happened in the app's thread-local destructor at
     process exit, if at all.
- If the app can't be reached in `on_exit` (it's busy), the recovery copies are left on disk for
  the next start rather than removed.

Quit paths checked:
- **Cmd+Q / the menu bar's Quit (macOS):** `terminate:` → winit `applicationWillTerminate` →
  eframe `exiting` → `on_exit`. Covered.
- **Last window closed** (close button, title bar Close, Dock/Finder quit Apple event → `Close`):
  `on_close_request` → `begin` sets `ui.quit` → root `Close` → `on_exit`. Covered.
- **`app.quit` (control channel / MCP):** sets `ui.quit` and closes the root window → `on_exit`.
  Covered. Its reply goes to the connection thread before the window closes. The process now ends
  sooner after that, so a client that reads very slowly could in theory see EOF instead of the
  reply (not seen).
- **The frame guard (panic):** isn't a quit. It writes recovery copies and the app keeps running,
  so it doesn't need this. A panic outside the guard aborts as before.
- **eframe error mid-run:** eframe also calls `on_exit` before `run_native` returns `Err`. On macOS
  that error now only reaches the log (eframe logs it), not the "window couldn't open" box. Startup
  failures still show the box: `on_exit` only runs once the app exists.

Part of #781.
2026-10-18 14:09:51 +07:00
BFlat 247530b60e Merge pull request #787 from pechnikov2026-star/community/dutch-catalog
M9: Dutch catalog, machine-drafted (nl.toml)
2026-10-18 13:58:06 +07:00
bflatastic ae89635e00 #787: translate the 11 strings main gained since the catalog was drafted 2026-10-18 13:25:03 +07:00
bflatastic 633455c380 #787: leave the progress docs to the maintainer 2026-10-18 13:25:02 +07:00
bflatastic 846a9f7214 Merge main into #787 2026-10-18 13:16:03 +07:00
BFlat 1c158ed81b Merge pull request #788 from storytold/community/batch-17
Community batch 17: glass labels, pencil preview, prototype delete fix, presenter menus, French catalog, Home search (6 PRs)
2026-10-18 13:14:30 +07:00
bflatastic c3a21a6ab3 Merge fix/765 into batch 17 2026-10-18 12:36:19 +07:00
bflatastic 976bbb84ef Merge fix/783 into batch 17 2026-10-18 12:36:18 +07:00
bflatastic 78fd30ab5f Merge fix/772 into batch 17 2026-10-18 12:36:18 +07:00
bflatastic 4458eb8802 Merge fix/776 into batch 17 2026-10-18 12:36:17 +07:00
bflatastic d1619f57bd Merge fix/771 into batch 17 2026-10-18 12:36:16 +07:00
bflatastic eac260f160 Merge fix/770 into batch 17 2026-10-18 12:36:15 +07:00
bflatastic a819e0a188 #776: Edit ▸ Delete from the menu bar removes the open interaction too (the guard moves to menus::invoke, which the Delete binding also reaches); has_selected_interaction goes 2026-10-18 12:28:27 +07:00
BFlat 3a9610d480 Merge pull request #785 from storytold/community/batch-16
Community batch 16: canvas label rename, View toggles kept, missing fonts, macOS glyphs (4 PRs) + rename selects the name
2026-10-18 12:19:46 +07:00
sanches ac606a2d43 M9: Dutch catalog, machine-drafted (nl.toml) 2026-10-18 08:17:12 +03:00
bflatastic f90becadab #765: search results use Home's own Open files rows and Recent files section (the full list stays known, so pictures and the selection survive a search); search_grid, SearchHit, prepare_search and search_file_card go; the field is widgets::text_input and Escape clears it 2026-10-18 11:52:32 +07:00
bflatastic 6a334fa354 #765: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:37:58 +07:00
bflatastic 2a3ea27605 Merge main into #765 2026-10-18 11:37:55 +07:00
bflatastic d654a14720 #783: put the new French rows in their sorted places 2026-10-18 11:37:21 +07:00
bflatastic d828e6379e #783: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:36:06 +07:00
bflatastic e46dda1036 Merge main into #783 2026-10-18 11:35:53 +07:00
bflatastic 1768be2965 #772: leave the presentation backdrop as the file sets it (owner decision) 2026-10-18 11:35:38 +07:00
bflatastic 0f8e29bc9f #772: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:35:34 +07:00
bflatastic 47c2b228a5 Merge main into #772 2026-10-18 11:35:19 +07:00
bflatastic d712f2ffd1 #776: collapse the nested if in select.none (clippy) 2026-10-18 11:34:43 +07:00
bflatastic 9b489a052e #776: drop the evidence log 2026-10-18 11:34:42 +07:00
bflatastic c8e93ddaaa Merge main into #776 2026-10-18 11:34:21 +07:00
bflatastic 52e3fe2967 Merge main into #771 2026-10-18 11:34:09 +07:00
bflatastic 78222e35d2 Merge main into #770 2026-10-18 11:34:04 +07:00
sanches e9be17427f M9: French catalog at 100% of the strings (still a draft)
The 91 UI strings, 12 command labels and 2 menu wordings the strings added since the French catalog was drafted left in English are translated in the catalog's conventions (NBSP before : ! and inside guillemets, one/other plurals, 0 singular): starter templates and the Templates tab, the home screen's recents table with its sorting and dates in the French way, dropping files to open or import, the Getting started tips with sharing by code, the Playground's bound-variable chip, smart-selection spacing, outline mode's options, the converting and panel-width commands. Appended to each section; still requires native review.
2026-10-18 05:51:39 +03:00
Yarila 1fe78aec66 M7: respect theme in presenter controls and backdrop 2026-10-18 03:14:53 +05:00
Yarila 1f0d9a9107 M7: show file previews in Home search 2026-10-18 01:49:22 +05:00
Yarila dd980c884f M7: search known files and templates from Home 2026-10-18 01:49:21 +05:00
Natnael-Dev 1e7983a550 docs(evidence): record verification evidence for #767 fix 2026-10-17 21:47:40 +03:00
Natnael-Dev bc5a2b8fa7 fix(proto): delete prototype interaction without deleting component (#767)
When an interaction noodle or interaction editor was selected in the
Prototype tab, pressing Delete or Backspace (or executing edit.delete)
targeted the selected layer/component instead of deleting the prototype
connection.

- Intercept Backspace and Delete in shortcuts::key_event and run_binding
  when an interaction is selected in the Prototype tab to remove the
  interaction using proto.interaction.remove rather than deleting the layer.
- Ensure clicking on empty canvas or outside noodles/handles deselects the
  active interaction noodle/editor.
- Close the interaction editor when navigating away from the Prototype tab.
- Add regression tests in proto_editor_tests verifying interaction deletion
  with Delete/Backspace keeps the component intact, and Escape closes the
  interaction editor before deselecting the component.
2026-10-17 21:45:51 +03:00
RealtaNuaandClaude Opus 5.5 7b9ebaacbb fix(ui): fitting Intensity translations; cut values end in "…", cut labels show on hover (#256)
From review:
- ru and pt-BR "Intensity" were too long for the glass field and ended
  in "…"; they are now "Яркость" and "Brilho", which fit.
- A value too long for its field ends in "…" instead of being clipped,
  so "100000" never shows as "10".
- A label cut short shows whole on hover.
- The every-language test now checks the Intensity label fits whole in
  each catalog (it fails with the old ru translation); the panel test
  checks the label isn't cut and that the field is the 98.5 points the
  language test measures against.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:50:52 +07:00
RealtaNuaandClaude Opus 5.5 1c16aeae09 fix(canvas): the Pencil previews its stroke in its own colour and weight (#256)
While drawing, the Pencil previewed its stroke in the theme's text
colour, 2 points wide: white in dark theme, so over a white shape
nothing showed until the mouse was let go. The preview now uses the
colour and weight the Pencil draws with (one constant, PENCIL_STROKE,
black at 2 pt), scaled by the zoom, at least 1 pt. It doesn't show pen
pressure or a brush, which only the finished vector has.

Refs #256 ("The 'draw' tool is not visible when you are drawing. You
can see it after you release the mouse.").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:25:51 +07:00
RealtaNuaandClaude Opus 5.5 a672a6650f fix(ui): glass settings: label the light intensity, keep labels in their fields (#256)
The glass effect's light intensity field had no label, and "Refraction
80%" and "Dispersion 50%" ran into the edge of their fields. The field
is labelled "Intensity" in every catalog. Text labels of number fields
take their measured width instead of 7 points per character, and a
label too long for its field ends in "…" before the value, which is
clipped to its own area. This changes every text-labelled number field:
Latin labels sit a little tighter, CJK labels get their real width.

Refs #256 ("There is one parameter that is not signed in the glass
effect editing options. The inscriptions also go beyond their
borders.").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:23:25 +07:00
叶林橦 d489235d2c engine: short loops in vector.reverseLoop really turn round
reverseLoop reversed the list and skipped shared segments, which only turns a
loop of three or more round: a one-segment loop has no order at all, and a
two-segment ring reads the same whichever way its list is turned, so both
answered reversed: true without moving.

Three or more segments: reverse the list and leave every stored end alone (a
shared one belongs to the neighbour too). One or two: store one segment the
other way round, skipping any segment another short loop lists; when none is
left to swap, refuse with a clear error and leave the document as it was.

Tests: two 2-segment areas over one line that share it, the shared segment
listed first in one area and last in the other; each reversal flips that area's
winding sign and leaves the shared segment and the neighbour alone. A shared
one-segment loop is refused. The 4-segment squares stay as the long-loop case.

Drop the two catalog.rs menu lines: they aren't in the reference app's menus,
and the Vector menu runs commands without parameters, so both would fail there.
2026-10-17 22:35:50 +08:00
叶林橦 57e455dfa7 engine: fill rules as vector.fillRule and vector.reverseLoop
Two vector commands behind the fill-rule panel, split out so they can land
on their own (the panel is held for a maintainer design decision):

- vector.fillRule {ids? | id?, region?, rule: nonZero|evenOdd} sets the
  winding rule a filled area fills by (the whole layer when region is left out).
- vector.reverseLoop {ids? | id?, region, loop} turns one loop round, which is
  how a hole is made and unmade.

reverseLoop no longer swaps the stored ends of a segment a loop shares with a
neighbour: reversing the loop's list already flips this loop's traversal of the
shared segment, so flipping its segment would turn the neighbour's area round
too (bug called out in review). A new test builds two areas that share a segment
and checks the shared segment and the neighbour's fill are left alone.

Adds the commands to the Vector menu (catalog/parity), their locale labels, and
vector_fill_rule_tests (rule per area or whole layer, junk refused, shared segment).
2026-10-17 20:57:26 +08:00
Ijas Hussain 95b91e602b M6: export variables as Dart constants for Flutter
variable.export takes format "dart", and Local variables > Export...
offers "Dart constants for Flutter...". The file has an abstract final
class per collection and mode (the default mode named after the
collection, the others after the collection and the mode) with a
static const Color, double, String or bool per variable.

Names become camelCase Dart identifiers, kept apart when two read the
same, with reserved words escaped; strings are escaped for Dart;
aliases are resolved to their value in that mode, so each class stands
alone. A generated file passes the Flutter analyzer and its constants
work in const widgets.
2026-10-17 12:31:58 +05:30
timrrrr e409f9efc0 M1: retain GitHub username for contributor credit 2026-10-17 01:48:50 +03:00
timrrrr 8a77ef6759 M1: preserve image fills with variable-bound resize dimensions 2026-10-17 01:18:07 +03:00
timrrrr 9397d2ca03 M1: crop image fills during Command/Ctrl resize (#702) 2026-10-16 23:16:26 +03:00
DubblbindongandClaude Sonnet 5.5 ed74fc67d0 feat(plugin): add saveFile permission with host-owned save dialog
Plugins can hand the host a file name, MIME type and bytes through the new
`save_file` import (plugin API 4, `permissions.saveFile`). The host asks the
user where to save (browser download on the web), so the plugin never sees a
path. Includes the SDK wrapper and the `export-names` example plugin.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Assisted-by: Claude (Anthropic)
2026-10-16 20:12:54 +02:00
叶林橦 e4562a0072 M7: Flutter code generation
- Code generation gains a Flutter target (ours; the incumbent generates CSS, iOS and Android only), which makes Dart the seventh language: `language: flutter` (with `dart` as an alias, so `CodeLang::from_id` maps both, `id()` is `flutter` and the label `Flutter`).

- `inspect.code` writes a Dart widget tree for the layer: a `Container` for a box, with its size, padding and a `BoxDecoration` (colour, radius, border, shadows); `Row` / `Column` for auto layout, with `spacing`, `crossAxisAlignment` and `textBaseline`, and `stretch` wherever a child fills the cross axis; `Expanded` for a child that fills the main axis; `Stack` with `Positioned` for free children; `Text` with a `TextStyle` (family, size, weight, style, height, letter spacing, decoration and alignment); `Image.asset` for images; `Opacity` for layer opacity; `SizedBox` for a capped subtree. Colours are `Color(0xAARRGGBB)` and `$` is escaped in string literals, as in Compose.

- `inspect.componentCode` writes a `StatelessWidget` for a component or set: properties become constructor parameters with their defaults, a variant property becomes a Dart enum (lowerCamel members, never a keyword), a boolean a `bool`, a text a `String`, a swap a `Widget` and a slot a `List<Widget>?`. Instances are calls that pass only what they change, and an instance's snippet puts the `import 'package:flutter/material.dart';` line before the call, since Dart's imports come first.

- Code connect takes Flutter too: `codeConnect.set {language: flutter}` stores an import path, a name and a template, and the snippet carries a Dart `import '…';` line. The default template passes named parameters (`Card(size: {size})`), as SwiftUI does, and import paths are checked as Dart imports (letters, digits, `_ . / : -`).

- The Inspect panel's language picker iterates `CodeLang::ALL`, so Flutter appears there and in the Connect to code note; the 13 UI catalogs and the component playground's language list follow.

- Docs: README, ROADMAP, docs/mcp.md, docs/gaps.md, docs/components-parity.md, docs/file-format-parity.md, docs/target-app-parity.md and docs/dev-mode-parity.md.

- Tests: Flutter cases in the codegen, component codegen and inspect behaviour suites.
2026-10-16 18:40:59 +08:00
AganzeFelicite 66dd31ad9f M7: find fonts activated through Adobe Fonts on macOS
Fonts activated through Adobe Fonts rendered in the fallback font without
a warning: Creative Cloud keeps them in its own folder as hidden files
named by number, which the font scan never read. The macOS scan now
includes ~/Library/Application Support/Adobe/CoreSync/plugins/livetype,
and files without an extension are indexed when they start with a font
signature. Fonts are read in place.

Closes #298
2026-10-16 11:07:33 +02:00
ahmedhosnypro d55781e56e Read the portal's colour scheme as the D-Bus variant it is
ReadOne replies with a variant body (signature v); deserializing it
straight to u32 (signature u) is a signature mismatch that zvariant
rejects, so the initial colour scheme never arrived and system mode
kept its light fallback. Take the Value and read the uint32 out of it.
2026-10-16 10:58:06 +03:00
Skyfuller1303 4ecf6e2f39 M4: preserve vector region fills across .fig import and native rendering
A filled area of a vector network can now carry its own paint stack. A region without paints of its own keeps drawing with the layer's fills, and a region with an explicitly empty list draws nothing, so the two cases stay distinguishable. The canvas, SVG and PDF output, boolean and hit-test geometry, vector editing and region operations all honour the per-region paints and keep them when regions are split, merged or reordered.

The .fig importer resolves the individual paint stacks of a layer's filled areas through its normal paint mapping and hands them to the document builder as private mapper data. Public REST vectorNetwork JSON never consumes that data and its region paints are dropped, so only validated .fig input can set them. Invalid entries are skipped with an import note. Live style and variable links on individual regions and writing region paints in the experimental .fig exporter are not part of this change.
2026-10-16 05:07:05 +02:00
ahmedhosnypro 4c494b804f Fix the system theme always light on Linux and the BSDs
winit 0.30 reports no system theme there (EventLoop::system_theme is
None), so the "system" preference resolved to light every time. When
the windowing stack gives no theme, the interface now follows the
freedesktop colour scheme instead: read once and watched live through
the settings portal (org.freedesktop.appearance / color-scheme) in the
desktop services (Services::system_dark), so GNOME's and KDE Plasma 6's
appearance setting switches the app without a restart. Where the OS
theme is reported (macOS, Windows, web) it still wins, and without any
answer the light fallback stands.
2026-10-16 03:16:41 +03:00
RealtaNuaandClaude Opus 5.5 e37e0bc20b perf(render): keep glass shapes a later render asks for again
A glass layer's coverage, distance field and normals depend only on its
outline and the zoom. A shape asked for again in a later render (the
next frame of a drag, a slider drag, a canvas patch, an export of the
same view) is now worked out whole, in its own pixel frame (translation
reduced to its fraction, in 1/65536 px with carry), and kept: renders
reuse it instead of redoing the distance transform and normals for
their crop.

A shape asked for only once (each step of a zoom, a pane dragged by
fractions of a pixel, a resize) is still worked out for just the part
shown, so those don't get slower: working out the whole pane when only
a corner is in view cost 1.8x before this rule.

The cache is bounded in bytes (128 MB, 32 MB on the web), keeps the
least recently used last, skips shapes over 4M px (1M on the web), and
parallel bands share one computation (OnceLock slot). Renders count
once per render_view / render_view_single; bands don't.

Output: digests of 30 renders (5 looks, square and rounded, scales 1,
1.37, 2) are identical to main; a cut through a rounded pane now equals
the whole render exactly.

Release timings (glass_render_timings, 2400 × 1600 px, min of 5):
canvas drag under the glass 49.5 → 26.5 ms, glass slider drag
104.8 → 61.6 ms, same view again 94.8 → 54.0 ms; zoom 105.0 → 93.7 ms
(no reuse: each zoom is new).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 06:01:49 +07:00
Mex 2795d9fdcb Merge branch 'main' into fix/outline-stroke-rings 2026-10-16 00:11:34 +02:00
Mex 9cb1dba1e2 M4: normalize contribution files to LF 2026-10-16 00:07:13 +02:00
Mex a0f6410397 M4: preserve stroke rings when outlining shapes 2026-10-16 00:03:04 +02:00
1105 changed files with 170345 additions and 42664 deletions

No files matched your search

+8
View File
@@ -3,5 +3,13 @@
[env]
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
# The Windows UI Automation adapter can be asked about a node that disappeared in the
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
[target.'cfg(all(windows, target_env = "gnu"))']
rustflags = ["--cfg=windows_slim_errors"]
[alias]
xtask = "run -q -p xtask --"
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/plugins"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+15 -73
View File
@@ -1,9 +1,7 @@
# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and
# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only
# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md),
# and Windows/FreeBSD packaging have their own workflows.
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -13,76 +11,20 @@ on:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links, and a smaller target dir.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every
# main state that gets a run is checked to the end.
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
timeout-minutes: 35
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
# Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at
# a sibling checkout that CI doesn't have): everything must build and pass without it.
- name: Test
run: cargo test --workspace --locked
- name: Dependency layering
run: cargo run --locked -q -p xtask -- layers
- name: Asset attribution
run: cargo run --locked -q -p xtask -- assets
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
# The engine, the UI crate and apps/figcraft-web must keep compiling for the browser.
- name: Check wasm-safe crates
run: cargo run --locked -q -p xtask -- wasm
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
+6 -6
View File
@@ -4,12 +4,10 @@
# before it can fail a release. Nothing is 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 pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
push:
branches: [release]
pull_request:
@@ -17,6 +15,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "17 2 * * *"
workflow_dispatch:
concurrency:
@@ -32,4 +32,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+24
View File
@@ -23,3 +23,27 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+6 -1
View File
@@ -24,8 +24,13 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
has-server: true
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+2
View File
@@ -31,3 +31,5 @@ jobs:
with:
app-name: figcraft
app-dir: FigCraft
install-relative-path: Programs/ArtCraft/FigCraft
install-scope: perUser
+2
View File
@@ -8,3 +8,5 @@ plans/
/log/
.DS_Store
*.profraw
.cache/
.vscode/
+6
View File
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
@@ -130,3 +131,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+18 -2
View File
@@ -25,8 +25,24 @@ Release builds embed fonts from the optional craft-fonts build input
| Family | Licence | Upstream |
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
FigCraft filters both families from every build in accordance with its asset policy.
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
## egui dependency font icons
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
## Arabic interface catalog
| Path | Contributor | Source | Licence | Review |
|---|---|---|---|---|
| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending |
+7
View File
@@ -62,6 +62,7 @@ code.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
safety nets, not substitutes for `Result`.
@@ -81,6 +82,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
@@ -130,3 +132,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+1230 -377
View File
File diff suppressed because it is too large. Load diff
+74 -9
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" }
figcraft-render = { path = "crates/render" }
figcraft-svg = { path = "crates/svg" }
figcraft-pdf = { path = "crates/pdf" }
figcraft-vectordrawable = { path = "crates/vectordrawable" }
figcraft-format = { path = "crates/format" }
figcraft-tools = { path = "crates/tools" }
figcraft-collab = { path = "crates/collab" }
@@ -32,16 +33,41 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate
# dependencies and is pinned separately for the suite updater rollout.
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Where the settings live: the suite's folder rules, portable mode included (craft-libs).
craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
# Command-line parsing for figcraft-cli (craft-contrib RFC-0012: clap derive, as photocraft does).
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
same-file = "1.0.6"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
imbl = { version = "7", features = ["serde"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
@@ -58,11 +84,20 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
# Line breaks between the words of Thai, Lao, Khmer and Burmese, which are written without spaces
# (#1219): ICU4X's line segmenter with its compiled data (pure Rust, Unicode-3.0 like the icu_*
# crates already in the lock, whose version it matches). figcraft-text picks the dictionaries, or
# in the browser the smaller LSTM models.
icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] }
# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
deunicode = "1.6"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha1 = "0.11"
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
@@ -74,9 +109,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
wat = "1.261"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
webbrowser = "1"
@@ -85,6 +120,9 @@ webbrowser = "1"
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
# Style lints from newer toolchains that fight index-heavy pixel code.
needless_range_loop = "allow"
@@ -98,11 +136,15 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
[profile.dev.package."*"]
opt-level = 2
# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
# the test build faster and the target dir smaller.
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.release]
lto = "thin"
@@ -113,9 +155,32 @@ codegen-units = 4
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
opt-level = "s"
# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate.
opt-level = "z"
lto = "fat"
codegen-units = 1
debug = false
strip = true
panic = "abort"
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
# panic while drawing unwinds without aborting.
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+17
View File
@@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+25 -10
View File
@@ -7,10 +7,14 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png" width="128" height="128" alt="FigCraft icon: an engraved chameleon on a mint-green tile">
</p>
<h1 align="center">FigCraft</h1>
<p align="center">
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
in a native app that opens instantly and works offline. Open source, and yours.
</p>
@@ -20,6 +24,9 @@
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-0EA5C6?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_figcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/figcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -62,7 +69,7 @@
<tr>
<td width="25%" valign="top">
<h3>🎛️ Familiar by design</h3>
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
</td>
<td width="25%" valign="top">
<h3>⚡ Native and fast</h3>
@@ -90,13 +97,13 @@
<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%">
<img src="docs/images/shot-3-prototype.png" alt="Four onboarding phone screens in the Prototype tab, joined by green 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%">
<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 a Playground preview of it and 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">
@@ -114,6 +121,16 @@
<p align="center"><sub><b>Live sessions</b>: three apps on one <code>figcraft-server</code> file, with cursors, selections, cursor chat and spotlight</sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-8-board.png" alt="A board file named Sprint retro on a dot grid: a Sprint 14 retro made from the retrospective template, with Went well, To improve and Next steps sections of sticky notes, vote counts of 3, 2, 1 and 1 on four stickies after a vote has ended, and below it a pull request flow chart from the flow chart template whose shapes are joined by straight and elbow connectors with Yes and No labels; a timer pill in the top right shows 4:30 left with pause, add a minute, stop and Results buttons" width="100%">
<p align="center"><sub><b>Boards</b>: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions</sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-9-devmode-measure.png" alt="Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu" width="100%">
<p align="center"><sub><b>Dev Mode measurements</b>: redlines placed between layers or along a side, saved in the file and following the layers · <code>examples/ridgeline-app.figcraft</code></sub></p>
</td>
</tr>
</table>
## Made in FigCraft
@@ -191,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
@@ -227,8 +244,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech 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 three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
@@ -262,7 +279,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
@@ -348,8 +365,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+218 -14
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error thLine truncated
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
~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);
native speakers' reviews of the six language drafts need people.
native speakers' reviews of the nine language drafts need people.
## By dimension
@@ -58,14 +58,14 @@ native speakers' reviews of the six language drafts need people.
|---|---|---|---|
| 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 | 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) |
| File formats | 60% (`.fig` read best effort with editable vectors and a total ZIP image byte budget; 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 and publishes to a server, 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 | 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) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
@@ -86,13 +86,14 @@ native speakers' reviews of the six language drafts need people.
| 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) |
| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
## Languages
@@ -102,18 +103,18 @@ native speakers' reviews of the six language drafts need people.
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 96% | draft (needs native review) |
| Korean | ko | 95% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
| Vietnamese | vi | 100% | draft (needs native review) |
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
@@ -126,14 +127,53 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
- **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged.
- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
- **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged.
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run
unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes
into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget
can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected
(`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets
65%.
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed
filenames open normally; the global `--no-system-fonts` flag also respects the terminator.
Binary regression coverage opens five such native files and checks their JSON info.
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **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.
@@ -316,15 +356,179 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. |
| 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). |
| 2026-10-30 | minor | Docs (found while landing #1649): merges that kept both sides of a conflict had left rows and status items twice. One row each now for `.fig` in `docs/file-format-parity.md` (four copies: #1234, #1236 and the #1076 ZIP budget kept, with the newer four-values-per-byte budget wording and #1649's connector routing), Real-time co-editing (three: #1069, #1065, #1063) and Live cursors (two: #1380, #1416) in `docs/collaboration-parity.md`, Windows x64 / arm64 in `docs/hardware-parity.md` (#1320, #965) and M0.9 in `docs/roadmap.md` (#1396, #996). The status lines of this file, `docs/roadmap.md`, `docs/gaps.md`, `docs/target-app-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` and `docs/file-format-parity.md` drop items listed more than once (302 in all, the first kept), and an unclosed parenthesis in target-app-parity's status line is closed. No wording changed otherwise. New `cargo xtask docs`, run by `ci`: a table row that repeats an earlier one (header rows apart) or a status item listed twice fails it. Numbers unchanged. Test: `docs_check::tests`. |
| 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. |
| 2026-10-30 | minor | M6, gap 6 (first slice): plugin widgets have hover styles, as the reference tool's widgets do. Any layer of a widget description, the widget itself too, may have `hover: {fill?, stroke?, opacity?, color? (text)}`, checked like the rest; on the canvas, while the pointer is over such a layer, it shows that style as a live preview (the text-preview mechanism: no undo step, never saved or sent to a live session), and moving away, leaving the canvas or any command (a click on the part acts on the document itself) puts it back; another live preview showing isn't replaced. `widget.hover {ids}` does the same for scripts and agents. No new plugin API: older FigCraft draws the layer as it is. The Counter example's buttons darken under the pointer. Timers and panels from widgets are still missing. Numbers unchanged. Tests: `widget_hover_tests`, `widget_tests::hover_styles_are_checked_and_normalised`. |
| 2026-10-30 | minor | M6, gap 1: `.fig` connectors saved without their path that are elbowed or curved were drawn as straight lines. They now run as FigCraft's board connectors do: in right angles (around the layers they join when the short way would cross them) or along one curve, leaving each layer from the side the file's `magnet` names, else the edge its point lies on, else the side facing the other end. The router (`route_points`) moved from the engine's boards into `figcraft-doc`, unchanged, so the importer and the board connectors share it. Straight connectors are unchanged. Checked by rendering the synthetic fixture with figcraft-cli; not on real files. Numbers unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style`, the elbowed connector in `other_layer_kinds_import_as_the_closest_layers`, the engine's board connector tests. |
| 2026-10-30 | minor | M6, gap 6: plugin panels (and plugin command forms) take a `slider` control: a number between a required `min` and `max`, drawn as a slider with the value field beside it, named for screen readers as a slider with its range. Dragging shows the value live and calls the plugin back once, when let go (one undo step); typing in the field or double-clicking the slider (back to the panel's value) do the same. The slider is the one the Type settings' variable axes used, moved into the shared widgets, which also gives those axis sliders a screen-reader name. Numbers unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go`. |
| 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings FILE] [--dry-run]` publishes a code repository's code connect mappings (`figcraft.connect.json`, as Export code connections writes it) to a design file: each link connects the components or sets of that name to their code for its language; links that don't fit are warnings and are skipped; the file is saved in place through the engine's `file.save` (whole or not at all, version history kept); a dry run saves nothing; nothing linked is exit code 1. Numbers unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, `args` tests. |
| 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. |
| 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. |
| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). |
| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). |
| 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged |
| 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
| 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. |
| 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. |
| 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged |
| 2026-10-30 | minor | M8, #1097: while the Assets panel showed a file, the app asked the server of every team library the file named for updates, with the link's token and a usage report, so a file someone sent could make the app contact any server. Now only team libraries you added (`library.add`) or checked on this computer are checked by themselves (kept with the preferences as hashes of their links); another one waits in **Assets ▸ Libraries** as "Not checked yet" until you click **Check for updates** (`library.check`, which plugins can't call). Placing or applying something from it is your own action and asks its server as before. Numbers unchanged. Tests: `library_beacon_tests` (engine and UI). |
| 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. |
| 2026-10-30 | minor | M8, #1117: `doc.list` returned the path on disk of every open file to any caller, plugins and attached AI agents included. It now gives an agent's calls (MCP, or a control call naming an `agent`) and plugins each file's `id`, name and state without `path`; the user's own scripts (the CLI, control calls without an agent) still get paths, and the app's UI reads its tabs directly. Numbers unchanged. Tests: `doc_list_paths_tests`. |
| 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. |
| 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). |
| 2026-10-30 | minor | M8, #1099: `command.batch` (and every grouped run: plugins, scripts, MCP `batch`) kept a full undo snapshot per step until the batch ended, so a long batch held one document copy per step. Inside a group, edits now add to the one undo step the group pushed, so a batch holds one snapshot however long it is; it is still one undo step that restores exactly the state before it, and a failure still rolls everything back. An `edit.undo` step inside a batch now undoes the batch's edits so far together. Measured on a 2,000-layer file with 160 KB of document colours (debug build): a 1,000-step batch went from +165 MiB peak and 80 to 100 ms to +10 MiB and about 50 ms (4,000 steps: +35 MiB, the step list and command journal). Numbers unchanged. Tests: `batch_undo_snapshot_tests`. |
| 2026-10-30 | minor | M8, #1101: the importers' 256-level depth cap could overflow the browser's 1 MB stack before it was reached. Measured per level: the REST import's layer walk (also the second stage of every `.fig` import) took about 4 KB, a whole megabyte at 256, and overflowed a 1 MB thread; it now runs on a work list in the same order (groups fitted once their children are in), a few KB in all. The `.fig` mapping, which builds each layer from its children's results, stays recursive at about 1.8 KB a level, 455 KB at the cap. Desktop and browser keep the 256-level cap, so nothing that loads today is cut off. Numbers unchanged. Tests: `deep_import_tests` (256 and 400 levels on a 1 MB thread). |
| 2026-10-30 | minor | M8, #1082 (the rest): the SVG and PDF exporters drew a pattern fill's source from depth 0 again, and the renders they embed (a layer with effects drawn alone, a pattern cell with effects) started at 0 too, so an export could nest its own levels, the renderer's and a pattern's on top of each other. A pattern source now exports one level below the layer it fills, and what the renderer draws for a layer goes on from that layer's depth (`figcraft_render::at_layer_depth`), so one cap bounds the whole export; the render of what lies beneath a layer (background blur, glass) is of the page's own layers and still counts from the page. Canvas masks, mask content and boolean and outline walks already carried the depth or have their own small caps. Numbers unchanged. Tests: `render_depth_tests`, `figcraft-svg` and `figcraft-pdf` `pattern_depth_tests`. |
| 2026-10-30 | minor | M8, #1082: a pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels; its source now goes on from the depth of the layer it fills, and the one cap bounds the whole render. Measured, a level takes about 6.5 KB of stack in a render (6.8 KB in a debug build), so the deepest allowed render needs about 1.7 MB: the canvas render threads, the save thread (it renders the thumbnail) and a band pool of their own now get 8 MiB stacks, like the desktop app's main thread (rayon's and other threads' default is 2 MiB). Numbers unchanged. Tests: `render_depth_tests` (the cap reached through a pattern; three 250-level chains of patterns in patterns rendered on a render thread's stack). |
| 2026-10-30 | minor | M8, #1114 (explicit variables files only): sibling variables files were read with a 256 MiB cap and a regular-file check, but an explicit path given to `variable.import`, or as the `variables` of `file.open` and `file.import`, was read whole with no limit. That path now goes through the same capped read: an oversized file, a folder or a device fails with "not a file of at most 268435456 bytes" and the document, tabs and recent files stay as they were. The check reads the file's size before reading it, so a file that grows or is replaced in between is not bounded; primary document reads and `image.place` are left for the rest of #1114. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged |
| 2026-10-30 | minor | M8, #1074: on `figcraft-server` every join of an idle file read its snapshot and log back and every join and leave rewrote its `meta.json`, all on the hub thread, so join/leave loops amplified into disk I/O. A file everyone left (its log synced) now stays loaded for a minute (at most 16 such files, 256 MiB together), and a join in that time takes it as it was with nothing read; its open time, which expiry counts in days, is written at most hourly and when the server stops. Edits are saved as before (logged, compacted when everyone leaves). Measured: 20 join/leave cycles of an unchanged file did 0 reads and 0 writes (40 reads before, plus a `meta.json` write per join and leave). Numbers unchanged. Tests: `idle_io_tests`. |
| 2026-10-30 | minor | M8, #1079: one budget for everything a `.figcraft` file unpacks when it opens. Each area had its own cap (document 4 GiB, images 4 GiB, library 1.5 GiB, versions 1 GiB, branches 1 GiB), so one file could make the app unpack about 10 GB. Now they also share one total, 6 GiB (2 GiB on 32-bit targets such as the browser), counted member by member in reading order: document, images, published library, version history, branches; the per-area caps stay. The document (a 900 000-layer one unpacks to 1.5 GB) and the images up to their own cap fit within it, so a file whose images pass 4 GiB (#1313) keeps as many as before, and images left out by the total are treated like those past the images' cap: they stay in the file and saving over it is refused rather than dropping them. History and branches past the total are skipped, and a document past it refused, each with the note "the file's contents together pass … MB". Numbers unchanged. Tests: `unpacked_total_tests`. |
| 2026-10-30 | minor | M8, #1571 (part): laggy drags and high CPU while the pointer only moves. Measured on a synthetic file of about 1,900 layers (24 auto layout frames of 48 rows, 600 shapes, 24 images; release build, 300 to 600 pointer moves at 60 per second): every pointer move draws about three egui frames (the move, egui's follow-up, a finished render), and the canvas hit-tested the hover on each of them; a drag of an auto layout frame laid its contents out again on every move, though a move can't change them. Now a hover where nothing changed since the last (same place, file, page, revision, selection, tool and modes) isn't sent to the tools again, and a frame that only moved keeps its own layout (its parent's still runs). Hover: the UI pass 0.91 to 0.49 ms a frame, CPU 33% to 22% of a core; dragging 648 layers: the UI pass 3.8 to 3.2 ms, the engine's normalise 3.8 to 2.1 ms a move, CPU about the same (117% to 116%). `ui.inspect` reports `frameTimes` ({frames, meanMs, maxMs}). Not yet: the selection overlay and the Design panel recompute every frame for a large selection (about 2 ms each at 648 layers), the canvas texture is uploaded whole after each render, and egui draws its follow-up frames. Tests: `canvas_hover_tests.rs`, `auto_layout_move_tests.rs`. |
| 2026-10-30 | minor | M8, #1077: two quadratic costs in the `.fig` import's instance expansion. Variable and collection nodes sat in the layer tree, so every instance of a main walked the ones under it again, and as they never become layers the layer cap never stopped them; they are now left out of the tree (variables are read from the node list as before), so everything an expansion visits counts against the cap. A nested instance copied all of its outer instance's override rows, and its property values and slot contents; it now takes only the rows addressed below it (they sort together, so the rest aren't visited) and shares the outer values, keeping only its own. Normal files map as before. Crafted files: 20 000 variables under a main with 2 000 instances 13.8 s → 21 ms; 50 000 override rows over 2 000 nested instances 40.5 s → 98 ms (native release, the same output). Numbers unchanged. Tests: `expansion_cost_tests`. |
| 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists every name a message @mentions besides those who wrote or reacted (once each in any case, a known name spelt as the file spells it, never an email address), so a name that has only been tagged shows up. `comment.next` takes the `mentions` filter and, like `comment.open`, returns the thread as `comment.list` lists it, with each message's `mentions`; `comment.prompt`'s bounded `comments` carry each thread's mentions (at most 32, names cut to 80 characters). One mention helper serves them all. Numbers unchanged. Tests: `comment_mentions_tests`. |
| 2026-10-30 | minor | M8, #1083: grid auto-placement tested every cell of a child's span at every position it tried (children × rows × columns × span area), so a crafted grid of wide children kept every layout pass busy. The grid's taken cells are now a bitmap: a span is checked a 64-bit word at a time, the search jumps past the cell that blocks it, a row's search ORs the span's rows once, and a column-anchored child looks on from where the last one of the same column and spans found room (cells only ever get taken). Placements are the same, checked against the old search on 400 random grids. A crafted 1000 × 1000 grid with 1000 full-width children: 192 ms → 0.3 ms; 3000 column-anchored wide children: 4.2 ms → 0.7 ms; ordinary grids unchanged (20 000 1 × 1 children 5.2 ms either way). Numbers unchanged. Tests: `grid_place_tests`. |
| 2026-10-30 | minor | M1, #1430: from the pen into vector edit mode. A double-click with the pen drew on: its second press added a point on top of the last one (a zero-length segment), a double-click meant to edit a layer drew a stray two-point vector on it, and Enter ran what Escape does. Now a press on the pen's last anchor adds nothing; a double-click while drawing ends the path at that point, and one that only started a path drops it and edits the layer under the pointer; Enter ends the path. Both go on editing the vector with the Move tool (points and handles); the next Enter leaves edit mode. In edit mode the pen's double-click and Enter take the Move tool too, a double-click taking back the point or new path its first press began. Escape still keeps the pen in edit mode for the next path (#760). Numbers unchanged. Tests: `pen_vector_edit_tests`. |
| 2026-10-30 | minor | M1: desktop recovery copies made while a version is previewed held the version shown instead of the file, so a crash during a preview brought the old version back as the unsaved file. What a recovery copy holds is now one shared helper, `figcraft_engine::branches::recovery_copy`: the document itself (`DocState::document_itself`, the same document the unsaved-changes check compares, not the version or live text preview shown in its place), its version history and its branches. The desktop copies and the browser's (#1580, which already took the document itself) both use it. Tests: `recovery_tests.rs` in the desktop and web apps (a copy made during a version preview recovers the file and its history). |
| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). |
| 2026-10-30 | minor | M6, #1342: FigJam `.jam` files open. A `.jam` is the same archive as a `.fig` (its canvas header reads `fig-jam.`), and the `.fig` reader already took it when a copy was renamed; now File ▸ Open, its file filter, drag and drop (home screen and canvas), `file.import` and the command line take `.jam` too, and the home screen's recent files show their thumbnails. What comes in is what the `.fig` reader makes of FigJam layers: stickies and shapes with text as frames with their generated layers, tables, stamps and the like as frames with an import note, connectors as vectors, and a layer type it doesn't know left out with a note naming it. The file opens as a design file, not a board, and its objects carry no board metadata yet. Tests: a synthetic `fig-jam.` canvas, bare and zipped (`board_import_tests.rs`), a `.jam` made from our own `.fig` export opened and imported (`jam_open_tests.rs`), the extension checks (`home_tests.rs`). |
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |
| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. |
| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. |
| 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. |
| 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). |
| 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). |
| 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged |
| 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged |
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
| 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged |
| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `<style>` sheets. Other colour spaces are taken as sRGB values and other mixes mixed in sRGB, each with an import note; a call that can't be read becomes a mid grey with a note. Bounded: calls over 2,048 characters, mixes nested more than 8 deep and more than 100,000 calls are not read. Tests: `css_color_tests` (svg). Numbers unchanged |
| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged |
| 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged |
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). |
| 2026-10-30 | minor | M8, #1416: an AI agent attached over MCP shows where it works, as a person in a live session does: its avatar in its own colour in the people row, named as its comments are signed ("Claude (agent)"), and an outline with its name around the layers its last edit changed or it last selected. It goes 8 seconds after its last command and shows only in the file it works in. Display only (no undo step, not saved, never synced to a live session); the engine records it in `Session::execute_as` (`figcraft_engine::agent`). Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) |
| 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) |
| 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged |
| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged |
| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged |
| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged |
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `[email protected]` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged |
| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged |
| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged |
| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged |
| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). |
| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged |
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged |
| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged |
| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged |
| 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. |
| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged |
| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) |
| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged |
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
| 2026-10-27 | minor | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. |
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. Numbers unchanged |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h |
| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
| 2026-10-15 | minor | Removing someone from a live session (#338): 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 session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. |
| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List<Widget>?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
| 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. |
+7
View File
@@ -22,3 +22,10 @@ figcraft-svg = { workspace = true }
figcraft-format = { workspace = true }
figcraft-mcp = { workspace = true }
serde_json = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
# 4.6 breaks Bash subcommand completion for hyphenated binary names (clap-rs/clap#6421).
clap_complete = "~4.5.67"
clap_mangen = "0.3"
@@ -0,0 +1,44 @@
//! Writes figcraft-cli's shell completions and recursive man pages into a directory:
//!
//! ```sh
//! cargo run -p figcraft-cli --example man-and-completions -- <out-dir>
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[path = "../src/args.rs"]
#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")]
mod args;
use std::path::Path;
use std::process::ExitCode;
use clap::{CommandFactory, ValueEnum};
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut command = args::Cli::command();
let name = command.get_name().to_owned();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut command, &name, dir)?;
}
// Completions build the command and add synthetic help subcommands. A fresh command keeps
// those out of the man pages (such as figcraft-cli-help-export.1).
clap_mangen::generate_to(args::Cli::command(), dir)
}
+546 -116
View File
@@ -1,138 +1,568 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
//! Unknown options are usage errors.
//! The command line, as clap types (craft-contrib RFC-0012). Values are parsed into their types
//! here, so a bad one is a usage error naming its flag (exit 2) before any work starts, and the
//! handlers never see it. A flag a subcommand doesn't take is a usage error, and so is one given
//! twice, except the automation roots, which repeat. A value starting with `-` is written
//! `--filter=-x`; after `--` every argument is a positional.
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::net::{Ipv4Addr, SocketAddr};
use crate::{CliError, CliResult};
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use figcraft_doc::{PrintSettings, VdCompat};
#[derive(Debug, Default)]
pub struct Args {
pub positional: Vec<String>,
values: BTreeMap<String, Vec<String>>,
flags: Vec<String>,
/// FigCraft without a window
///
/// Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig
/// (best effort; `info` lists what wasn't imported), .sketch (Sketch 48 or newer, best effort),
/// .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer).
#[derive(Parser)]
#[command(name = "figcraft-cli", bin_name = "figcraft-cli", version, arg_required_else_help = true, after_help = "See docs/cli.md and docs/mcp.md.")]
pub struct Cli {
/// Lay text out with the fonts built into FigCraft only, for renders that match on every
/// machine (or set FIGCRAFT_NO_SYSTEM_FONTS=1). Otherwise installed fonts are indexed the
/// first time text is laid out, as in the app
#[arg(long, global = true)]
pub no_system_fonts: bool,
#[command(subcommand)]
pub command: Command,
}
impl Args {
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
let mut a = Args::default();
let mut it = argv.iter();
while let Some(arg) = it.next() {
let name = if let Some(n) = arg.strip_prefix("--") {
n
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
n
} else {
a.positional.push(arg.clone());
continue;
};
let (name, inline) = match name.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (name, None),
};
if valued.contains(&name) {
let v = match inline {
Some(v) => v,
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
};
a.values.entry(name.to_string()).or_default().push(v);
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
return Err(CliError::Usage(format!("unknown option `{arg}`")));
#[derive(Subcommand)]
pub enum Command {
Info(InfoArgs),
Export(ExportArgs),
Convert(ConvertArgs),
Render(RenderArgs),
Run(RunArgs),
Commands(CommandsArgs),
Mcp(McpArgs),
/// Publish plugins to a server's plugin directory
Plugin {
#[command(subcommand)]
command: PluginCommand,
},
/// Publish a code repository's component mappings (code connect) to a design file
Connect {
#[command(subcommand)]
command: ConnectCommand,
},
Perf(PerfArgs),
}
/// The folders a file subcommand may touch. Without either flag it reads and writes as the user
/// who started it; with one, reads are allowed only inside a read or write root and writes only
/// inside a write root, so a read root alone denies writing altogether.
#[derive(Args)]
pub struct RootArgs {
/// Confine file access: a folder that may be read (repeatable; it must exist)
#[arg(long, value_name = "DIR")]
pub automation_read_root: Vec<String>,
/// Confine file access: a folder that may be read and written (repeatable; it must exist)
#[arg(long, value_name = "DIR")]
pub automation_write_root: Vec<String>,
}
/// Pages, node counts, top-level frames, style/variable/image counts
#[derive(Args)]
pub struct InfoArgs {
/// The document to read
#[arg(value_name = "FILE")]
pub file: String,
/// Print JSON
#[arg(long)]
pub json: bool,
#[command(flatten)]
pub roots: RootArgs,
}
/// Export frames, a layer or the whole file
///
/// <OUT> is a folder: one file per top-level frame of the page. An <OUT> with an image extension,
/// or --node, writes one file. `fig` (or an <OUT> ending in .fig) writes the whole file as .fig,
/// experimental: other apps may not open every file. `penpot` (or an <OUT> ending in .penpot)
/// writes the whole file as a Penpot 2.x archive. The format comes from --format, else <OUT>'s
/// extension, else png.
#[derive(Args)]
pub struct ExportArgs {
/// The document to read
#[arg(value_name = "FILE")]
pub file: String,
/// The folder or file to write
#[arg(short = 'o', long = "out", value_name = "OUT")]
pub out: String,
/// png, jpg, svg, webp, pdf, xml (Android Vector Drawable), fig or penpot
#[arg(long, value_name = "FORMAT", value_parser = format)]
pub format: Option<String>,
#[command(flatten)]
pub scale: ScaleArg,
/// The page to export (default the first)
#[arg(long, value_name = "NAME")]
pub page: Option<String>,
/// The layer to export, by id (12 or 0:12) or name
#[arg(long, value_name = "ID|NAME")]
pub node: Option<String>,
/// PDF: grow each page by this bleed, in points or a length like 3mm or 0.125in (up to 2in)
#[arg(long, value_name = "LENGTH", value_parser = bleed, default_value = "0")]
pub bleed: f64,
/// PDF: draw crop marks outside the bleed
#[arg(long)]
pub crop_marks: bool,
/// PDF: draw bleed marks outside the bleed
#[arg(long)]
pub bleed_marks: bool,
/// PDF: write CMYK colour
#[arg(long)]
pub cmyk: bool,
/// Android Vector Drawable: what the format can't draw is approximated (bestEffort), left out
/// (exact) or dropped without notes (ignore)
#[arg(long, value_name = "bestEffort|exact|ignore", value_parser = vd_compat, default_value = "bestEffort")]
pub vd_compat: VdCompat,
#[command(flatten)]
pub roots: RootArgs,
}
impl ExportArgs {
/// The PDF print options.
pub fn print(&self) -> PrintSettings {
PrintSettings { bleed: self.bleed, crop_marks: self.crop_marks, bleed_marks: self.bleed_marks, cmyk: self.cmyk }
}
}
/// `--scale`, shared by `export` and `render`.
#[derive(Args)]
pub struct ScaleArg {
/// Raster scale, in (0, 64]: 2 or 2x
// allow_negative_numbers makes `--scale -3` an out-of-range value rather than an unexpected argument.
#[arg(long, value_name = "N", value_parser = scale, default_value = "1", allow_negative_numbers = true)]
pub scale: f64,
}
/// Convert by extension (.figcraft, .json, .svg, .png, .jpg, .webp, .pdf, .fig, .penpot)
#[derive(Args)]
pub struct ConvertArgs {
/// The document to read
#[arg(value_name = "IN")]
pub input: String,
/// The file to write; its extension picks the format
#[arg(value_name = "OUT")]
pub output: String,
#[command(flatten)]
pub roots: RootArgs,
}
/// Render the whole page with its background as png, jpg or webp
#[derive(Args)]
pub struct RenderArgs {
/// The document to read
#[arg(value_name = "FILE")]
pub file: String,
/// The image to write
#[arg(short = 'o', long = "out", value_name = "OUT.png")]
pub out: String,
#[command(flatten)]
pub scale: ScaleArg,
/// The page to render (default the first)
#[arg(long, value_name = "NAME")]
pub page: Option<String>,
#[command(flatten)]
pub roots: RootArgs,
}
/// Run a script of commands in a headless session, one JSON result line per step
///
/// The script is a JSON array (or JSON lines) of {"command": id, "params": {...}}, run as one
/// all-or-nothing undo step.
#[derive(Args)]
pub struct RunArgs {
/// The script to run
#[arg(value_name = "SCRIPT.json|SCRIPT.jsonl")]
pub script: String,
/// Open this file first instead of a new document
#[arg(long = "in", value_name = "FILE")]
pub input: Option<String>,
/// Save the result here, by extension
#[arg(long, value_name = "FILE")]
pub out: Option<String>,
#[command(flatten)]
pub roots: RootArgs,
}
/// List every command with its label, shortcut, undoable flag (*) and parameters
#[derive(Args)]
pub struct CommandsArgs {
/// Print JSON
#[arg(long)]
pub json: bool,
/// Only the commands whose id, label or parameters contain this text
#[arg(long, value_name = "TEXT")]
pub filter: Option<String>,
}
/// Serve MCP on stdio: headless, or attached to a running app
///
/// The automation roots confine the headless session's file access. A server started with
/// --attach is confined by the app it attaches to: start that app with `figcraft --control PORT`
/// and the same roots.
#[derive(Args)]
pub struct McpArgs {
/// Attach to the app started with `figcraft --control PORT`; a bare port means 127.0.0.1:PORT.
/// Refuses the automation roots
#[arg(long, value_name = "PORT", value_parser = attach, conflicts_with_all = ["automation_read_root", "automation_write_root"])]
pub attach: Option<String>,
#[command(flatten)]
pub roots: RootArgs,
}
#[derive(Subcommand)]
pub enum PluginCommand {
Publish(PublishArgs),
}
/// Send a plugin folder to a figcraft-server's plugin directory as a new version
///
/// The folder holds figcraft-plugin.json, its module and an optional assets/ folder. The server's
/// key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out
/// of the shell history); it is never printed.
#[derive(Args)]
pub struct PublishArgs {
/// The plugin folder
#[arg(value_name = "FOLDER")]
pub folder: String,
/// The server, ws://host:port/ (optionally ?key=KEY)
#[arg(long, value_name = "URL")]
pub server: String,
}
#[derive(Subcommand)]
pub enum ConnectCommand {
Publish(ConnectPublishArgs),
}
/// Link a design file's components to their code as a figcraft.connect.json says, and save it
///
/// The mappings are what Dev Mode's Export code connections writes, kept in the code repository:
/// each links a component (or component set) of that name to its code component for a language.
/// Entries that don't fit (an unknown component, a bad template) are reported and skipped.
#[derive(Args)]
pub struct ConnectPublishArgs {
/// The design file (.figcraft), saved in place
#[arg(value_name = "FILE")]
pub file: String,
/// The mappings
#[arg(long, value_name = "FILE", default_value = "figcraft.connect.json")]
pub mappings: String,
/// Report what would be linked without saving the file
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub roots: RootArgs,
}
/// Measure the hot paths on synthetic documents and print Markdown tables
#[derive(Args)]
pub struct PerfArgs {
/// Document sizes in layers, each clamped to 10..=2,000,000
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "1000,10000")]
pub layers: Vec<usize>,
/// Component instance counts, capped to 100,000; 0 skips the components table
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "2000")]
pub instances: Vec<usize>,
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected a number in (0, 64], like 2 or 2x")]
Scale,
#[error("expected points or a length like 3mm or 0.125in, up to 2in")]
Bleed,
#[error("expected bestEffort, exact or ignore")]
VdCompat,
#[error("expected png, jpg, svg, webp, pdf, xml, fig or penpot")]
Format,
#[error("expected whole numbers, like 1000,10_000")]
WholeNumber,
#[error("expected a port number or host:port")]
Attach,
}
fn scale(s: &str) -> Result<f64, ValueError> {
s.trim_end_matches('x').parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0).ok_or(ValueError::Scale)
}
fn bleed(s: &str) -> Result<f64, ValueError> {
PrintSettings::parse_length(s).filter(|b| *b <= PrintSettings::MAX_BLEED).ok_or(ValueError::Bleed)
}
fn vd_compat(s: &str) -> Result<VdCompat, ValueError> {
VdCompat::from_key(s).ok_or(ValueError::VdCompat)
}
/// `--format`, any case; kept as text for the exporter, which also reads it from the extension.
fn format(s: &str) -> Result<String, ValueError> {
let f = s.to_ascii_lowercase();
match f.as_str() {
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" | "fig" | "penpot" => Ok(f),
_ => Err(ValueError::Format),
}
}
fn whole_number(s: &str) -> Result<usize, ValueError> {
s.trim().replace('_', "").parse().map_err(|_| ValueError::WholeNumber)
}
/// `mcp --attach`: `host:port` as given, or a bare port on 127.0.0.1.
fn attach(s: &str) -> Result<String, ValueError> {
if s.contains(':') {
return Ok(s.to_owned());
}
let port: u16 = s.parse().map_err(|_| ValueError::Attach)?;
Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string())
}
/// Parse the arguments, the program name first. clap reads them as `OsString`s, so a non-Unicode
/// one is a usage error rather than a panic. A bare `--help` or `-h` before `--` is help even after
/// a mistake on the same line (`mcp --attach nope --help`, `export -o --help`), which clap would
/// report first.
pub fn parse(args: Vec<OsString>) -> Result<Cli, clap::Error> {
let mut cmd = Cli::command();
match cmd.try_get_matches_from_mut(&args) {
Ok(matches) => Cli::from_arg_matches(&matches),
Err(e) => match args.iter().skip(1).take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") {
Some(flag) => {
let help: Vec<OsString> = subcommand_path(&cmd, &args).into_iter().chain([flag.clone()]).collect();
Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e))
}
}
Ok(a)
None => Err(e),
},
}
}
pub fn value(&self, k: &str) -> Option<String> {
self.values.get(k).and_then(|v| v.last()).cloned()
}
/// Every value of an option that may be given more than once, in order.
pub fn values(&self, k: &str) -> &[String] {
self.values.get(k).map_or(&[], Vec::as_slice)
}
pub fn flag(&self, k: &str) -> bool {
self.flags.iter().any(|f| f == k)
}
pub fn one_positional(&self, usage: &str) -> CliResult<String> {
match self.positional.as_slice() {
[one] => Ok(one.clone()),
_ => Err(CliError::Usage(format!("expected: {usage}"))),
}
}
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
/// `--bleed-marks`, `--cmyk`.
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
let bleed = match self.value("bleed") {
None => 0.0,
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
};
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
pub fn scale(&self) -> CliResult<f64> {
match self.value("scale") {
None => Ok(1.0),
Some(s) => s
.trim_end_matches('x')
.parse::<f64>()
.ok()
.filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0)
.ok_or_else(|| CliError::Usage(format!("--scale must be a number in (0, 64], got `{s}`"))),
}
}
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
match self.value(k) {
None => Ok(default.to_vec()),
Some(s) => s
.split(',')
.map(|v| v.trim().replace('_', "").parse::<usize>().map_err(|_| CliError::Usage(format!("--{k} must be whole numbers, got `{s}`"))))
.collect(),
}
/// The program name and the subcommands `args` names (`figcraft-cli plugin publish`): at each
/// level, the first argument that isn't a flag, while it names a subcommand.
fn subcommand_path(cmd: &clap::Command, args: &[OsString]) -> Vec<OsString> {
let mut path: Vec<OsString> = args.first().cloned().into_iter().collect();
let mut at = cmd;
let mut rest = args.iter().skip(1).filter(|a| !a.to_string_lossy().starts_with('-'));
while let Some(sub) = rest.next().and_then(|a| a.to_str()).and_then(|name| at.find_subcommand(name)) {
path.push(sub.get_name().into());
at = sub;
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
use clap::error::ErrorKind;
fn v(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
use super::{Cli, Command, ConnectCommand, PluginCommand, parse};
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect())
}
fn ok(args: &[&str]) -> Command {
match try_parse(args) {
Ok(cli) => cli.command,
Err(e) => panic!("{args:?}: {e}"),
}
}
fn kind(args: &[&str]) -> ErrorKind {
match try_parse(args) {
Ok(_) => panic!("{args:?} parsed"),
Err(e) => e.kind(),
}
}
#[test]
fn parses_options_and_flags() {
let a = Args::parse(&v(&["in.figcraft", "-o", "out", "--scale=2", "--json"]), &["o", "scale"], &["json"]).unwrap();
assert_eq!(a.positional, ["in.figcraft"]);
assert_eq!(a.value("o").as_deref(), Some("out"));
assert_eq!(a.scale().unwrap(), 2.0);
assert!(a.flag("json"));
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
assert_eq!(a.values("root"), ["a", "b"]);
assert_eq!(a.value("root").as_deref(), Some("b"));
assert!(a.values("none").is_empty());
fn the_definition_is_consistent() {
Cli::command().debug_assert();
}
#[test]
fn rejects_bad_input() {
assert!(matches!(Args::parse(&v(&["--nope"]), &[], &[]), Err(CliError::Usage(_))));
assert!(matches!(Args::parse(&v(&["-o"]), &["o"], &[]), Err(CliError::Usage(_))));
assert!(matches!(Args::parse(&v(&["--json=1"]), &[], &["json"]), Err(CliError::Usage(_))));
let a = Args::parse(&v(&["--scale", "abc"]), &["scale"], &[]).unwrap();
assert!(a.scale().is_err());
let a = Args::parse(&v(&["--scale", "-3"]), &["scale"], &[]).unwrap();
assert!(a.scale().is_err());
// Negative numbers are positionals, not options.
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
assert_eq!(a.positional, ["-5"]);
fn info_and_the_roots() {
let Command::Info(a) = ok(&["info", "a.figcraft", "--json", "--automation-read-root", "in", "--automation-read-root=more"]) else {
panic!("info")
};
assert_eq!(a.file, "a.figcraft");
assert!(a.json);
assert_eq!(a.roots.automation_read_root, ["in", "more"]);
assert!(a.roots.automation_write_root.is_empty());
let Command::Info(a) = ok(&["info", "--json", "--", "--drawing.figcraft"]) else { panic!("info") };
assert_eq!(a.file, "--drawing.figcraft");
}
#[test]
fn export_takes_every_documented_option() {
let Command::Export(a) = ok(&[
"export",
"app.figcraft",
"-o",
"poster.pdf",
"--format=PDF",
"--scale",
"2x",
"--page",
"Screens",
"--node",
"Poster",
"--bleed",
"3mm",
"--crop-marks",
"--bleed-marks",
"--cmyk",
"--vd-compat",
"exact",
]) else {
panic!("export")
};
assert_eq!((a.file.as_str(), a.out.as_str(), a.format.as_deref()), ("app.figcraft", "poster.pdf", Some("pdf")));
assert_eq!((a.scale.scale, a.page.as_deref(), a.node.as_deref()), (2.0, Some("Screens"), Some("Poster")));
let print = a.print();
assert!((print.bleed - 3.0 * 72.0 / 25.4).abs() < 1e-9, "{}", print.bleed);
assert!(print.crop_marks && print.bleed_marks && print.cmyk);
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::Exact);
// The defaults, and `--out` for `-o`.
let Command::Export(a) = ok(&["export", "app.figcraft", "--out=exports/"]) else { panic!("export") };
assert_eq!((a.out.as_str(), a.format.as_deref(), a.scale.scale, a.print().bleed), ("exports/", None, 1.0, 0.0));
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::default());
}
#[test]
fn convert_render_run_commands_perf() {
let Command::Convert(a) = ok(&["convert", "in.svg", "out.figcraft"]) else { panic!("convert") };
assert_eq!((a.input.as_str(), a.output.as_str()), ("in.svg", "out.figcraft"));
let Command::Render(a) = ok(&["render", "doc.figcraft", "--out", "doc.png", "--scale=0.5", "--page", "P"]) else { panic!("render") };
assert_eq!((a.out.as_str(), a.scale.scale, a.page.as_deref()), ("doc.png", 0.5, Some("P")));
let Command::Run(a) = ok(&["run", "s.jsonl", "--in", "a.figcraft", "--out", "b.figcraft", "--automation-write-root", "."]) else {
panic!("run")
};
assert_eq!((a.script.as_str(), a.input.as_deref(), a.out.as_deref()), ("s.jsonl", Some("a.figcraft"), Some("b.figcraft")));
assert_eq!(a.roots.automation_write_root, ["."]);
let Command::Commands(a) = ok(&["commands", "--json", "--filter=--help"]) else { panic!("commands") };
assert!(a.json);
assert_eq!(a.filter.as_deref(), Some("--help"));
let Command::Perf(a) = ok(&["perf"]) else { panic!("perf") };
assert_eq!((a.layers, a.instances), (vec![1000, 10_000], vec![2000]));
let Command::Perf(a) = ok(&["perf", "--layers", "1000,10_000, 50000", "--instances=0"]) else { panic!("perf") };
assert_eq!((a.layers, a.instances), (vec![1000, 10_000, 50_000], vec![0]));
}
#[test]
fn mcp_and_plugin_publish() {
let Command::Mcp(a) = ok(&["mcp"]) else { panic!("mcp") };
assert_eq!(a.attach, None);
let Command::Mcp(a) = ok(&["mcp", "--attach", "7979"]) else { panic!("mcp") };
assert_eq!(a.attach.as_deref(), Some("127.0.0.1:7979"));
let Command::Mcp(a) = ok(&["mcp", "--attach=localhost:7979"]) else { panic!("mcp") };
assert_eq!(a.attach.as_deref(), Some("localhost:7979"));
let Command::Mcp(a) = ok(&["mcp", "--automation-read-root", "in", "--automation-write-root", "out"]) else { panic!("mcp") };
assert_eq!((a.roots.automation_read_root, a.roots.automation_write_root), (vec!["in".to_owned()], vec!["out".to_owned()]));
let Command::Plugin { command: PluginCommand::Publish(a) } = ok(&["plugin", "publish", "dist/counter", "--server", "ws://h:47900/"]) else {
panic!("plugin")
};
assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/"));
let Command::Connect { command: ConnectCommand::Publish(a) } = ok(&["connect", "publish", "ui.figcraft"]) else { panic!("connect") };
assert_eq!((a.file.as_str(), a.mappings.as_str(), a.dry_run), ("ui.figcraft", "figcraft.connect.json", false));
let Command::Connect { command: ConnectCommand::Publish(a) } =
ok(&["connect", "publish", "ui.figcraft", "--mappings", "code/map.json", "--dry-run"])
else {
panic!("connect")
};
assert_eq!((a.mappings.as_str(), a.dry_run), ("code/map.json", true));
}
#[test]
fn the_font_flag_goes_before_or_after_the_subcommand() {
for args in [&["--no-system-fonts", "commands"][..], &["commands", "--no-system-fonts"]] {
assert!(try_parse(args).is_ok_and(|cli| cli.no_system_fonts), "{args:?}");
}
assert!(try_parse(&["commands"]).is_ok_and(|cli| !cli.no_system_fonts));
}
#[test]
fn bad_arguments_are_usage_errors() {
use ErrorKind::{
ArgumentConflict, DisplayHelpOnMissingArgumentOrSubcommand, InvalidSubcommand, InvalidValue, MissingRequiredArgument, TooManyValues,
UnknownArgument, ValueValidation,
};
let cases: &[(&[&str], ErrorKind)] = &[
(&[], DisplayHelpOnMissingArgumentOrSubcommand),
(&["bogus"], InvalidSubcommand),
(&["version"], InvalidSubcommand),
(&["export", "x.figcraft"], MissingRequiredArgument),
(&["info", "--nope", "a"], UnknownArgument),
(&["info", "a", "b"], UnknownArgument),
(&["info", "--json=1", "a"], TooManyValues),
(&["render", "a", "-o"], InvalidValue),
(&["export", "a", "-o", "b", "--scale", "0"], ValueValidation),
(&["export", "a", "-o", "b", "--scale", "-3"], ValueValidation),
(&["export", "a", "-o", "b", "--scale", "65"], ValueValidation),
(&["export", "a", "-o", "b", "--bleed", "lots"], ValueValidation),
(&["export", "a", "-o", "b", "--bleed", "3in"], ValueValidation),
(&["export", "a", "-o", "b", "--vd-compat", "sloppy"], ValueValidation),
(&["export", "a", "-o", "b", "--format", "bmp"], ValueValidation),
(&["export", "a", "-o", "b", "--page", "A", "--page", "B"], ArgumentConflict),
(&["perf", "--shapes", "lots"], UnknownArgument),
(&["perf", "--layers", "10,x"], ValueValidation),
(&["perf", "--layers", "10", "--layers", "20"], ArgumentConflict),
(&["mcp", "--attach", "notaport"], ValueValidation),
(&["mcp", "--attach", "7979", "--automation-read-root", "."], ArgumentConflict),
(&["mcp", "--automation-write-root"], InvalidValue),
(&["mcp", "stray"], UnknownArgument),
(&["convert", "a"], MissingRequiredArgument),
(&["plugin", "publish", "dist"], MissingRequiredArgument),
(&["commands", "--filter", "--json"], InvalidValue),
];
for (args, want) in cases {
assert_eq!(kind(args), *want, "{args:?}");
assert_eq!(try_parse(args).err().map(|e| e.exit_code()), Some(2), "{args:?}");
}
assert_eq!(try_parse(&["plugin"]).err().map(|e| e.exit_code()), Some(2));
}
#[test]
fn help_and_version_exit_0() {
assert_eq!(kind(&["--version"]), ErrorKind::DisplayVersion);
assert_eq!(kind(&["-V"]), ErrorKind::DisplayVersion);
for args in [
&["--help"][..],
&["-h"],
&["help"],
&["help", "export"],
&["export", "x.figcraft", "-o", "out", "--help"],
&["export", "-o", "--help"],
&["mcp", "--attach", "notaport", "--help"],
&["mcp", "--bogus", "-h"],
&["--no-system-fonts", "plugin", "publish", "--bogus", "--help"],
] {
let e = try_parse(args).err();
assert_eq!(e.as_ref().map(clap::Error::kind), Some(ErrorKind::DisplayHelp), "{args:?}");
assert_eq!(e.map(|e| e.exit_code()), Some(0), "{args:?}");
}
// The help is the subcommand's, even after a mistake.
let help = try_parse(&["plugin", "publish", "--bogus", "--help"]).err().map(|e| e.to_string()).unwrap_or_default();
assert!(help.contains("Usage: figcraft-cli plugin publish"), "{help}");
// After `--`, `-h` is a file name.
let Command::Info(a) = ok(&["info", "--", "-h"]) else { panic!("info") };
assert_eq!(a.file, "-h");
}
/// Every subcommand's help renders and documents each of its options.
#[test]
fn every_subcommand_has_help() {
let mut cli = Cli::command();
cli.build();
let mut todo = vec![cli];
while let Some(mut cmd) = todo.pop() {
let help = cmd.render_long_help().to_string();
assert!(help.contains("Usage: figcraft-cli"), "{}: {help}", cmd.get_name());
for arg in cmd.get_arguments().filter(|a| !matches!(a.get_long(), Some("help" | "version"))) {
assert!(arg.get_help().is_some(), "{} {}", cmd.get_name(), arg.get_id());
}
todo.extend(cmd.get_subcommands().filter(|s| s.get_name() != "help").cloned());
}
}
}
+267 -40
View File
@@ -1,16 +1,23 @@
//! The file subcommands: info, export, convert, render and run.
use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
use crate::{CliError, CliResult};
pub fn load(path: &str) -> CliResult<Document> {
load_noted(path).map(|(d, _)| d)
let (doc, notes) = load_noted(path)?;
if ext(path) == "penpot" {
for note in notes {
eprintln!("note: {note}");
}
}
Ok(doc)
}
/// The document with the loader's notes (repairs of a damaged file, or import warnings).
@@ -19,19 +26,30 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
}
fn ext(path: &str) -> String {
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
fn image_format(e: &str) -> Option<ExportFormat> {
match e {
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
_ => None,
}
}
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
/// confined run can only write inside a write root (#833).
///
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
/// `Roots::check` has already refused a link that leads out of the root.
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
}
if figcraft_engine::file_access::current().is_some() {
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
}
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
}
@@ -108,7 +126,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
"fig" => fig_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
"penpot" => penpot_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig, penpot)"))),
};
write(out, &bytes)
}
@@ -122,6 +141,14 @@ fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
Ok(bytes)
}
fn penpot_bytes(doc: &Document) -> CliResult<Vec<u8>> {
let (bytes, warnings) = figcraft_engine::cmd::file::penpot_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
for w in warnings {
eprintln!("note: {w}");
}
Ok(bytes)
}
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
@@ -196,10 +223,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
fn sanitize(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
let s = s.trim().trim_matches('.').to_string();
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
let s = figcraft_engine::cmd::file::safe_file_name(name);
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
}
/// What `export` exports and how.
@@ -210,6 +240,8 @@ pub struct Target {
pub node: Option<String>,
/// PDF only.
pub print: PrintSettings,
/// Android Vector Drawable (XML) only.
pub vd_compat: VdCompat,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
@@ -220,19 +252,28 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
if t.format.as_deref().map_or(ext(out) == "penpot", |f| f.eq_ignore_ascii_case("penpot")) {
write(out, &penpot_bytes(&doc)?)?;
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
let page = page_named(&doc, t.page.as_deref())?;
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => {
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
}
Some(f) => image_format(&f.to_ascii_lowercase())
.ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?,
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of = |ids: &[NodeId]| {
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
// What an Android Vector Drawable changed or left out, like the .fig notes.
for w in warnings {
eprintln!("note: {w}");
}
Ok::<_, CliError>(bytes)
};
if single {
let bytes = match target {
@@ -244,7 +285,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
let ids = match target {
Some(t) => vec![t],
None => {
@@ -257,14 +298,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
return Err(CliError::Failed("the page is empty: nothing to export".into()));
}
let mut used = std::collections::BTreeSet::new();
let mut next_suffix: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for id in ids {
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
name = format!("{base} {k}");
k += 1;
}
let base_lower = base.to_lowercase();
let next = next_suffix.entry(base_lower).or_insert(1);
let name = loop {
let k = *next;
*next += 1;
let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") };
if used.insert(candidate.to_lowercase()) {
break candidate;
}
};
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
write(&path, &bytes_of(&[id])?)?;
println!("{path}");
@@ -272,8 +318,34 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
return Err(CliError::Failed(format!(
"{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
)));
}
Ok(())
}
fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
if let Some((input, images_left_out)) = input {
refuse_dropping_images(input, out, images_left_out)?;
}
if let Some(images_left_out) = active.images_lost_saving_to(out) {
refuse_dropping_images(out, out, images_left_out)?;
}
Ok(())
}
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
let doc = load(input)?;
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
refuse_dropping_images(input, out, loaded.images_past_budget)?;
if ext(input) == "penpot" {
for note in &loaded.issues {
eprintln!("note: {note}");
}
}
let doc = loaded.doc;
let page = doc
.current_page
.filter(|p| doc.pages.contains(p))
@@ -298,7 +370,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
Ok(Value::Array(a)) => a,
Ok(Value::Object(o)) if o.contains_key("commands") => {
@@ -318,56 +390,64 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
.enumerate()
.map(|(i, s)| {
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?;
let params = match s.get("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
Ok((cmd.to_string(), params))
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
})
.collect()
}
/// The largest script `run` reads (one may carry images as base64): a device, a pipe or a larger
/// file is refused before it is read (#1114).
pub const MAX_SCRIPT: u64 = 1 << 30;
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let bytes = figcraft_engine::file_access::read_capped(script, MAX_SCRIPT).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
let (mut s, input_images_left_out) = match input {
Some(path) => {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
s
let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
(s, left_out)
}
None => Session::new(),
None => (Session::new(), 0),
};
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
// steps before it, and a step that fails takes the whole script back instead of leaving the
// earlier steps applied. The result lines are held back until the script is through, so none of
// them reports work that was rolled back.
let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
for (i, step) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.batch_step(&mut scope, &step.command, &step.params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
@@ -379,6 +459,34 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
Ok(ExitCode::SUCCESS)
}
/// Link the components of `file` (a `.figcraft`) to their code as `mappings` (a
/// `figcraft.connect.json`) says, and save it in place unless `dry_run`. Prints what was linked;
/// entries that were skipped are warnings on stderr, and linking nothing fails (exit code 1).
pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult<ExitCode> {
if ext(file) != "figcraft" {
return Err(CliError::Usage(format!("{file}: connect publish saves code links in a .figcraft file")));
}
let mut s = Session::empty();
s.execute("file.open", &json!({"path": file})).map_err(|e| CliError::Failed(format!("can't open {file}: {e}")))?;
let r = s.execute("codeConnect.import", &json!({"path": mappings})).map_err(|e| CliError::Failed(format!("{mappings}: {e}")))?;
for w in r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str) {
eprintln!("warning: {w}");
}
let linked = r["linked"].as_u64().unwrap_or(0);
if linked == 0 {
eprintln!("error: no component of {file} matches a link in {mappings}: nothing published");
return Ok(ExitCode::from(1));
}
if dry_run {
println!("{linked} component link(s) from {mappings} would be published to {file} (dry run: not saved)");
return Ok(ExitCode::SUCCESS);
}
// The engine's save: written whole or not at all, with the file's version history.
s.execute("file.save", &json!({})).map_err(|e| CliError::Failed(format!("can't save {file}: {e}")))?;
println!("published {linked} component link(s) from {mappings} to {file}");
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -392,6 +500,9 @@ mod tests {
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
assert!(parse_script("not json\n").is_err());
assert!(parse_script("42").is_err());
// `args` for `params` would run the step without them (#1413).
let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string();
assert!(e.contains("step 1: unknown key `args`"), "{e}");
}
#[test]
@@ -399,6 +510,14 @@ mod tests {
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
assert_eq!(sanitize(" .hidden. "), "hidden");
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
}
#[test]
@@ -451,6 +570,57 @@ mod tests {
dir
}
/// A script path that is a folder or a device is refused before anything is read (#1114).
#[test]
fn a_script_that_isnt_a_regular_file_is_refused() {
let dir = temp_dir("script-caps");
let mut bad = vec![dir.to_string_lossy().into_owned()];
if cfg!(unix) {
bad.push("/dev/zero".into());
}
for path in bad {
let Err(CliError::Failed(e)) = run_script(&path, None, None) else { panic!("{path} ran") };
assert!(e.contains("is a folder, not a file") || e.contains("isn't a regular file"), "{path}: {e}");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// `connect publish` links the file's components as the mappings say and saves the file in
/// place; a dry run saves nothing, and mappings that link nothing fail.
#[test]
fn connect_publish_links_components_and_saves_the_file() {
let dir = temp_dir("connect");
let file = dir.join("ui.figcraft");
let file = file.to_str().unwrap();
let mut s = Session::new();
let frame = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Card"})).unwrap();
s.execute("component.create", &json!({"ids": [frame["id"]]})).unwrap();
s.execute("file.saveAs", &json!({"path": file})).unwrap();
let mappings = dir.join("figcraft.connect.json");
let link =
|component: &str| json!({"component": component, "language": "react", "importPath": "@acme/ui", "name": "Card", "template": "<Card />"});
std::fs::write(&mappings, json!({"version": 1, "links": [link("Card"), link("Nope")]}).to_string()).unwrap();
let mappings = mappings.to_str().unwrap();
let links = || {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": file})).unwrap();
let doc = s.doc().unwrap();
doc.nodes().filter(|n| n.name == "Card").map(|n| n.code_links.len()).sum::<usize>()
};
assert_eq!(connect_publish(file, mappings, true).unwrap(), ExitCode::SUCCESS);
assert_eq!(links(), 0, "a dry run saves nothing");
assert_eq!(connect_publish(file, mappings, false).unwrap(), ExitCode::SUCCESS, "an unknown component is only a warning");
assert_eq!(links(), 1, "Card is linked to its code in the saved file");
let none = dir.join("none.json");
std::fs::write(&none, json!({"version": 1, "links": [link("Nope")]}).to_string()).unwrap();
assert_eq!(connect_publish(file, none.to_str().unwrap(), false).unwrap(), ExitCode::from(1));
assert!(matches!(connect_publish(dir.join("ui.svg").to_str().unwrap(), mappings, false), Err(CliError::Usage(_))));
assert!(matches!(connect_publish(file, dir.join("missing.json").to_str().unwrap(), false), Err(CliError::Failed(_))));
std::fs::remove_dir_all(dir).unwrap();
}
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
/// as "no parent" and the layer landed on the page — with every step reporting success.
@@ -482,6 +652,43 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
#[test]
fn a_script_refers_to_named_steps_and_paths() {
let dir = temp_dir("run-ref");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let top = doc.node(s.page().unwrap()).unwrap().children.clone();
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
assert_eq!(names, ["Card", "Copy"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// Two steps with one name fail before anything runs.
#[test]
fn a_script_with_a_name_twice_runs_nothing() {
let dir = temp_dir("run-dup-name");
let script = dir.join("script.json");
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
@@ -501,4 +708,24 @@ mod tests {
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
let dir = temp_dir("script-images-left-out");
let input = dir.join("big.figcraft");
std::fs::write(&input, b"kept").unwrap();
let link = dir.join("link.figcraft");
std::fs::hard_link(&input, &link).unwrap();
let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
let duplicate = Session::new();
let active = duplicate.ds().unwrap();
for out in [input, link] {
let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
}
assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
std::fs::remove_dir_all(dir).unwrap();
}
}
+52 -130
View File
@@ -1,6 +1,6 @@
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin
//! directory and measure performance.
//! directory, publish code connect mappings to a design file and measure performance.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod args;
@@ -10,50 +10,38 @@ mod perf;
use std::io::Write;
use std::process::ExitCode;
use args::Args;
use args::{Cli, Command, ConnectCommand, PluginCommand, RootArgs};
use serde_json::{Value, json};
pub const USAGE: &str = "figcraft-cli — FigCraft without a window
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
fn roots_of(r: &RootArgs) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
if r.automation_read_root.is_empty() && r.automation_write_root.is_empty() {
return Ok(None);
}
Ok(Some(figcraft_engine::file_access::Roots::new(&r.automation_read_root, &r.automation_write_root).map_err(CliError::Failed)?))
}
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
figcraft-cli 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] [--automation-read-root DIR]... [--automation-write-root DIR]...
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 (`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).
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
writes only inside a write root, everything else refused. A running app confines itself: start it
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
/// Runs `f` confined to the roots `r` carries.
fn confined<R>(r: &RootArgs, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
let roots = roots_of(r)?.map(std::sync::Arc::new);
figcraft_engine::file_access::confined(roots.as_ref(), f)
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
// `args_os`: a non-Unicode argument is clap's usage error, not a panic.
let cli = match args::parse(std::env::args_os().collect()) {
Ok(cli) => cli,
Err(e) => {
// Help and `--version` on stdout with 0, usage errors on stderr with 2.
let _ = e.print();
return ExitCode::from(u8::try_from(e.exit_code()).unwrap_or(2));
}
};
match run(cli) {
Ok(code) => code,
Err(CliError::Usage(msg)) => {
eprintln!("error: {msg}\n\n{USAGE}");
eprintln!("error: {msg}\n\nFor more information, try '--help'.");
ExitCode::from(2)
}
Err(CliError::Failed(msg)) => {
@@ -65,7 +53,7 @@ fn main() -> ExitCode {
#[derive(Debug)]
pub enum CliError {
/// Bad arguments: usage is printed, exit code 2.
/// Bad arguments found after parsing (an output extension no format writes): exit code 2.
Usage(String),
/// The operation failed: exit code 1.
Failed(String),
@@ -79,99 +67,38 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
if system_fonts {
fn run(cli: Cli) -> CliResult<ExitCode> {
if !cli.no_system_fonts {
// Text lays out with the installed fonts, like the desktop app, so headless renders and
// auto layout sizes match what users see.
figcraft_text::scan_system_fonts_on_demand();
} else {
// Nothing scans them then, not even a `.sketch` import matching its fonts.
figcraft_text::opt_out_of_system_fonts();
}
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
match cmd.as_str() {
"help" | "--help" | "-h" => {
println!("{USAGE}");
Ok(ExitCode::SUCCESS)
}
"--version" | "-V" | "version" => {
println!("figcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &[], &["json"])?;
let file = a.one_positional("info <file>")?;
files::info(&file, a.flag("json"))
}
"export" => {
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
match cli.command {
Command::Info(a) => confined(&a.roots, || files::info(&a.file, a.json)),
Command::Export(a) => {
let target =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
files::export(&file, &out, &target)
files::Target { print: a.print(), vd_compat: a.vd_compat, scale: a.scale.scale, format: a.format, page: a.page, node: a.node };
confined(&a.roots, || files::export(&a.file, &a.out, &target))
}
"convert" => {
let a = Args::parse(rest, &[], &[])?;
match a.positional.as_slice() {
[i, o] => files::convert(i, o),
_ => Err(CliError::Usage("convert <in> <out>".into())),
}
}
"render" => {
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
}
"run" => {
let a = Args::parse(rest, &["in", "out"], &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("commands takes no positional arguments".into()));
}
commands(a.flag("json"), a.value("filter").as_deref());
Command::Convert(a) => confined(&a.roots, || files::convert(&a.input, &a.output)),
Command::Render(a) => confined(&a.roots, || files::render(&a.file, &a.out, a.scale.scale, a.page.as_deref())),
Command::Run(a) => confined(&a.roots, || files::run_script(&a.script, a.input.as_deref(), a.out.as_deref())),
Command::Commands(a) => {
commands(a.json, a.filter.as_deref());
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
(true, _) => None,
// The app runs the commands: only its own roots can confine them.
(false, Some(_)) => {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
};
mcp(a.value("attach").as_deref(), roots)
// `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them.
Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?),
Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server),
Command::Connect { command: ConnectCommand::Publish(a) } => confined(&a.roots, || files::connect_publish(&a.file, &a.mappings, a.dry_run)),
Command::Perf(a) => {
let sizes: Vec<usize> = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances, !cli.no_system_fonts)
}
"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, system_fonts)
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
}
@@ -223,13 +150,8 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {
let addr = if p.contains(':') {
p.to_string()
} else {
format!("127.0.0.1:{}", p.parse::<u16>().map_err(|_| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?)
};
let b = figcraft_mcp::Attached::connect(&addr)
Some(addr) => {
let b = figcraft_mcp::Attached::connect(addr)
.map_err(|e| format!("can't reach the FigCraft app at {addr}: {e} (start it with `figcraft --control PORT`)"))?;
Box::new(b)
}
-11
View File
@@ -535,17 +535,6 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
#[cfg(test)]
mod tests {
#[test]
fn layer_sizes_parse_as_a_list() {
use crate::args::Args;
let argv = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let a = Args::parse(&argv(&["--layers", "1000,10_000, 50000"]), &["layers"], &[]).unwrap();
assert_eq!(a.usizes("layers", &[1]).unwrap(), vec![1000, 10_000, 50_000]);
assert_eq!(Args::parse(&[], &["layers"], &[]).unwrap().usizes("layers", &[7]).unwrap(), vec![7]);
let bad = Args::parse(&argv(&["--layers", "10,x"]), &["layers"], &[]).unwrap();
assert!(bad.usizes("layers", &[1]).is_err());
}
#[test]
fn measures_components_at_a_small_scale() {
let mut t = super::Table { rows: Vec::new() };
-174
View File
@@ -1,174 +0,0 @@
//! The binary end to end.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn bad_arguments_exit_2_without_panicking() {
for args in [
&[][..],
&["bogus"],
&["export"],
&["export", "x.figcraft"],
&["info", "--nope"],
&["render", "a", "-o"],
&["perf", "--shapes", "lots"],
&["mcp", "--attach", "notaport"],
// An attached app confines itself: roots go to `figcraft --control`.
&["mcp", "--attach", "7979", "--automation-read-root", "."],
&["mcp", "--automation-write-root"],
&["convert", "a"],
] {
let out = bin().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("USAGE"), "{args:?}");
assert!(!err.contains("panicked"));
}
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
}
#[test]
fn run_export_info_convert() {
let d = tmp("run");
let script = d.join("s.jsonl");
std::fs::write(&script, "{\"command\":\"frame.create\",\"params\":{\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"name\":\"A\",\"props\":{\"fill\":\"#ff0000\"}}}\n{\"command\":\"frame.create\",\"params\":{\"x\":200,\"y\":0,\"width\":30,\"height\":30,\"name\":\"B\"}}\n").unwrap();
let file = d.join("doc.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|l| l["ok"] == true));
let info = bin().args(["info", "--json"]).arg(&file).output().unwrap();
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
assert_eq!(v["pages"][0]["frames"].as_array().unwrap().len(), 2);
let dir = d.join("png");
assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--scale", "2"]).status().unwrap().success());
let a = std::fs::read(dir.join("A.png")).unwrap();
assert_eq!(&a[16..24], &[0, 0, 0, 200, 0, 0, 0, 100]);
assert!(dir.join("B.png").exists());
let svg = d.join("b.svg");
assert!(bin().arg("export").arg(&file).arg("-o").arg(&svg).args(["--node", "B"]).status().unwrap().success());
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
let json = d.join("doc.json");
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
let png = d.join("doc.png");
assert!(bin().arg("convert").arg(&json).arg(&png).status().unwrap().success());
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
// A failing step: nonzero exit, error line printed.
let bad = d.join("bad.json");
std::fs::write(&bad, r#"[{"command":"select.all"},{"command":"nope"}]"#).unwrap();
let out = bin().arg("run").arg(&bad).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stdout).contains("\"ok\":false"));
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();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap();
writeln!(stdin, "garbage").unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"create_node","arguments":{{"type":"ellipse"}}}}}}"#)
.unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(lines[1]["error"]["code"], -32700);
assert_eq!(lines[2]["result"]["isError"], false);
}
#[test]
fn mcp_over_stdio_confined_to_automation_roots() {
let d = tmp("roots");
for sub in ["in", "out", "outside"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
// A root that isn't a folder stops the server before it serves.
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
let mut child = bin()
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
let call = |id: u32, tool: &str, args: serde_json::Value| {
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
};
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 6);
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
let error = |i: usize| lines[i]["result"]["isError"] == true;
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn commands_json_lists_everything() {
let out = bin().args(["commands", "--json"]).output().unwrap();
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(v.as_array().unwrap().len() > 200);
}
+393
View File
@@ -0,0 +1,393 @@
//! The binary end to end.
use std::io::Write;
use std::process::{Command, Stdio};
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// Conversion and image commands must report the Penpot loader's notes too.
#[test]
fn penpot_import_notes_reach_convert_export_and_render() {
use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger};
use figcraft_doc::{Document, NodeKind};
let d = tmp("penpot-notes");
let mut doc = Document::new("Prototype");
let page = doc.first_page().unwrap();
let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap();
let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap();
doc.node_mut(home).unwrap().interactions.push(Interaction {
trigger: Trigger::OnClick,
actions: vec![Action::Navigate { dest, preserve_scroll: false }],
transition: Transition::Instant,
duration_ms: 0,
easing: Easing::Linear,
delay_ms: 0,
});
let input = d.join("source.penpot");
std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap();
for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] {
let mut cmd = bin();
cmd.arg(command).arg(&input);
if command != "convert" {
cmd.arg("-o");
}
let out = cmd.arg(d.join(name)).output().unwrap();
let notes = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{command}: {notes}");
assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn option_terminator_allows_dash_prefixed_filenames() {
let dir = tmp("option-terminator");
let doc = figcraft_doc::Document::new("Dash-prefixed file");
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] {
std::fs::write(dir.join(name), &bytes).unwrap();
let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["file"], name);
assert_eq!(info["name"], "Dash-prefixed file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn global_font_flag_respects_option_values() {
// A value starting with `-` is written with `=` (RFC-0012).
for filter in ["--filter=--", "--filter=--no-system-fonts"] {
let output = bin().args(["commands", filter, "--no-system-fonts", "--json"]).output().unwrap();
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
}
}
#[test]
fn global_font_flag_can_precede_the_subcommand() {
let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(!serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).unwrap().is_empty());
}
#[test]
fn bad_arguments_exit_2_without_panicking() {
for args in [
&[][..],
&["bogus"],
&["export"],
&["export", "x.figcraft"],
&["info", "--nope"],
&["render", "a", "-o"],
&["perf", "--shapes", "lots"],
&["mcp", "--attach", "notaport"],
// An attached app confines itself: roots go to `figcraft --control`.
&["mcp", "--attach", "7979", "--automation-read-root", "."],
&["mcp", "--automation-write-root"],
&["convert", "a"],
] {
let out = bin().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("Usage: figcraft-cli") || err.contains("try '--help'"), "{args:?}: {err}");
assert!(!err.contains("panicked"));
}
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
}
/// `--help` / `-h` after a subcommand prints that subcommand's help on stdout and exits 0, as it
/// does first (#1155), whatever else is on the line; nothing runs.
#[test]
fn help_after_a_subcommand_prints_the_usage() {
for args in [
&["--help"][..],
&["-h"],
&["help"],
&["mcp", "--help"],
&["mcp", "-h"],
&["info", "--help"],
&["export", "--help"],
&["export", "x.figcraft", "-o", "out", "--help"],
&["convert", "-h"],
&["render", "--help"],
&["run", "--help"],
&["commands", "--help"],
&["plugin", "publish", "--help"],
&["perf", "--help"],
&["mcp", "--attach", "notaport", "--help"],
&["mcp", "--bogus", "--help"],
] {
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("Usage: figcraft-cli"), "{args:?}: {text}");
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
}
// A value that happens to be `--help` is still a value, not a request for help.
let out = bin().args(["commands", "--filter=--help"]).output().unwrap();
assert_eq!(out.status.code(), Some(0));
assert!(!String::from_utf8_lossy(&out.stdout).contains("Usage:"), "--filter took `--help` as its text");
}
#[test]
fn run_export_info_convert() {
let d = tmp("run");
let script = d.join("s.jsonl");
std::fs::write(&script, "{\"command\":\"frame.create\",\"params\":{\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"name\":\"A\",\"props\":{\"fill\":\"#ff0000\"}}}\n{\"command\":\"frame.create\",\"params\":{\"x\":200,\"y\":0,\"width\":30,\"height\":30,\"name\":\"B\"}}\n").unwrap();
let file = d.join("doc.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|l| l["ok"] == true));
let info = bin().args(["info", "--json"]).arg(&file).output().unwrap();
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
assert_eq!(v["pages"][0]["frames"].as_array().unwrap().len(), 2);
let dir = d.join("png");
assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--scale", "2"]).status().unwrap().success());
let a = std::fs::read(dir.join("A.png")).unwrap();
assert_eq!(&a[16..24], &[0, 0, 0, 200, 0, 0, 0, 100]);
assert!(dir.join("B.png").exists());
let svg = d.join("b.svg");
assert!(bin().arg("export").arg(&file).arg("-o").arg(&svg).args(["--node", "B"]).status().unwrap().success());
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
let json = d.join("doc.json");
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
let png = d.join("doc.png");
assert!(bin().arg("convert").arg(&json).arg(&png).status().unwrap().success());
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
// A failing step: nonzero exit, error line printed.
let bad = d.join("bad.json");
std::fs::write(&bad, r#"[{"command":"select.all"},{"command":"nope"}]"#).unwrap();
let out = bin().arg("run").arg(&bad).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stdout).contains("\"ok\":false"));
std::fs::remove_dir_all(&d).ok();
}
/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`,
/// `%`, `#` and commas stay, what file systems refuse becomes `_`.
#[test]
fn export_file_names_keep_what_the_app_keeps() {
let d = tmp("names");
let script = d.join("s.json");
let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"];
let steps: Vec<serde_json::Value> = names
.iter()
.enumerate()
.map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}}))
.collect();
std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap();
let file = d.join("doc.figcraft");
assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success());
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] {
assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::<Vec<_>>());
}
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();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap();
writeln!(stdin, "garbage").unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"create_node","arguments":{{"type":"ellipse"}}}}}}"#)
.unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(lines[1]["error"]["code"], -32700);
assert_eq!(lines[2]["result"]["isError"], false);
}
#[test]
fn mcp_over_stdio_confined_to_automation_roots() {
let d = tmp("roots");
for sub in ["in", "out", "outside"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
// A root that isn't a folder stops the server before it serves.
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
let mut child = bin()
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
let call = |id: u32, tool: &str, args: serde_json::Value| {
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
};
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 6);
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
let error = |i: usize| lines[i]["result"]["isError"] == true;
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn commands_json_lists_everything() {
let out = bin().args(["commands", "--json"]).output().unwrap();
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(v.as_array().unwrap().len() > 200);
}
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
/// the same commands behave as they always did.
#[test]
fn file_subcommands_are_confined_to_automation_roots() {
let d = tmp("file-roots");
let _ = std::fs::remove_dir_all(&d);
for sub in ["in", "out"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
// A document to work on, made unconfined.
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", out_of(&made));
// Inside the roots: renders as usual.
let ok = bin()
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.output()
.unwrap();
assert!(ok.status.success(), "{}", out_of(&ok));
assert!(d.join("out/ok.png").is_file());
// A write outside the write root is refused, and nothing is written.
let escaped = d.join("escaped.png");
let refused = bin()
.args([
"render",
&p("in/doc.figcraft"),
"-o",
&escaped.to_string_lossy(),
"--automation-read-root",
&p("in"),
"--automation-write-root",
&p("out"),
])
.output()
.unwrap();
assert!(!refused.status.success());
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
assert!(!escaped.exists(), "the refused render wrote a file anyway");
// No write root at all: writing is refused outright, reading still works.
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
assert!(!no_write.status.success());
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
// A read outside every root is refused: `info` on the document with only `out` readable.
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
assert!(!read_refused.status.success());
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
// `convert` and `run` take them as well, and a root that is not a folder is refused.
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
assert!(!missing.status.success());
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
// Unconfined, the same render still writes wherever it is told.
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
assert!(plain.status.success(), "{}", out_of(&plain));
assert!(escaped.is_file());
let _ = std::fs::remove_dir_all(&d);
}
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
/// replaced the link with a regular file, because that write stages the bytes and renames them
/// into place.
#[cfg(unix)]
#[test]
fn an_unconfined_write_follows_a_symbolic_link() {
let d = tmp("symlink-out");
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("real")).unwrap();
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let target = d.join("real/target.png");
std::fs::write(&target, b"placeholder").unwrap();
let link = d.join("link.png");
std::os::unix::fs::symlink(&target, &link).unwrap();
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
let written = std::fs::read(&target).unwrap();
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
let _ = std::fs::remove_dir_all(&d);
}
@@ -1,5 +1,4 @@
//! 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};
@@ -1,6 +1,5 @@
//! 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;
@@ -67,3 +66,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
/// A synthetic `.sketch` file: one page with one Helvetica text.
const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA=";
/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to
/// `--no-system-fonts` too: the run still knows only the built-in families.
#[test]
fn sketch_imports_keep_to_no_system_fonts() {
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("open.json");
let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
.arg("run")
.arg(&script)
.arg("--no-system-fonts")
.env_remove("FIGCRAFT_NO_SYSTEM_FONTS")
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines[0]["ok"], json!(true), "{lines:?}");
let fonts: Vec<String> = serde_json::from_value(lines[1]["result"].clone()).unwrap();
assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts");
}
@@ -0,0 +1,164 @@
//! Generated CLI documentation uses the parser's command definition, without running commands.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
#[path = "../../examples/man-and-completions.rs"]
#[expect(dead_code, reason = "These tests call generate directly; the example's main is smoke-tested separately.")]
mod generator;
struct TempDir(PathBuf);
impl TempDir {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn tmp(name: &str) -> std::io::Result<TempDir> {
static NEXT: AtomicUsize = AtomicUsize::new(0);
loop {
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{n}-{name}", std::process::id()));
match fs::create_dir(&dir) {
Ok(()) => return Ok(TempDir(dir)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
}
#[test]
fn completions_name_the_cli_its_subcommands_and_options() -> std::io::Result<()> {
let temp = tmp("completions")?;
let dir = temp.path();
generator::generate(dir)?;
for name in ["figcraft-cli.bash", "figcraft-cli.elv", "figcraft-cli.fish", "_figcraft-cli.ps1", "_figcraft-cli"] {
let text = fs::read_to_string(dir.join(name))?;
for word in [
"figcraft-cli",
"info",
"export",
"convert",
"render",
"run",
"commands",
"mcp",
"plugin",
"perf",
"publish",
"no-system-fonts",
"format",
"automation-read-root",
"server",
] {
assert!(text.contains(word), "{name} does not contain {word}");
}
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
}
Ok(())
}
#[test]
fn bash_dispatch_states_have_matching_option_blocks() -> std::io::Result<()> {
let temp = tmp("bash-completion-states")?;
let dir = temp.path();
generator::generate(dir)?;
let text = fs::read_to_string(dir.join("figcraft-cli.bash"))?;
let states: Vec<_> =
text.lines().filter_map(|line| line.trim().strip_prefix("cmd=\"")?.strip_suffix('"')).filter(|state| !state.is_empty()).collect();
assert!(states.len() > 1, "the completion has no subcommand dispatch states");
for state in states {
let block = format!("{state})");
assert!(text.lines().any(|line| line.trim() == block), "Bash dispatch state {state} has no option block");
}
Ok(())
}
#[test]
fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::Result<()> {
let temp = tmp("man-pages")?;
let dir = temp.path();
generator::generate(dir)?;
let mut pages = Vec::new();
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "1") {
pages.push(path.file_name().unwrap_or_default().to_string_lossy().into_owned());
}
}
pages.sort();
assert_eq!(
pages,
[
"figcraft-cli-commands.1",
"figcraft-cli-connect-publish.1",
"figcraft-cli-connect.1",
"figcraft-cli-convert.1",
"figcraft-cli-export.1",
"figcraft-cli-info.1",
"figcraft-cli-mcp.1",
"figcraft-cli-perf.1",
"figcraft-cli-plugin-publish.1",
"figcraft-cli-plugin.1",
"figcraft-cli-render.1",
"figcraft-cli-run.1",
"figcraft-cli.1",
]
);
for name in &pages {
let text = fs::read_to_string(dir.join(name))?.replace("\\-", "-");
assert!(text.contains(".SH NAME"), "{name} has no NAME section");
assert!(text.contains(name.trim_end_matches(".1")), "{name} has the wrong command name");
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
}
for (page, option) in [
("figcraft-cli.1", "--no-system-fonts"),
("figcraft-cli-export.1", "--format"),
("figcraft-cli-export.1", "--automation-read-root"),
("figcraft-cli-plugin-publish.1", "--server"),
("figcraft-cli-connect-publish.1", "--dry-run"),
] {
let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-");
assert!(text.contains(option), "{page} does not contain {option}");
}
Ok(())
}
#[test]
fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Result<()> {
let temp = tmp("generated-docs-repeat")?;
let dir = temp.path();
let output = dir.join("new").join("docs");
assert!(!output.exists());
generator::generate(&output)?;
let page = output.join("figcraft-cli.1");
let first = fs::read(&page)?;
assert!(!first.is_empty());
fs::write(&page, "old page")?;
generator::generate(&output)?;
assert_eq!(fs::read(page)?, first);
assert_eq!(fs::read_dir(output)?.count(), 18);
Ok(())
}
#[test]
fn generation_returns_filesystem_errors() -> std::io::Result<()> {
let temp = tmp("generated-docs-errors")?;
let dir = temp.path();
let file = dir.join("not-a-directory");
fs::write(&file, "keep me")?;
assert!(generator::generate(&file).is_err());
assert_eq!(fs::read_to_string(&file)?, "keep me");
// A valid directory can still fail when writing one of the generated pages.
fs::create_dir(dir.join("figcraft-cli.1"))?;
assert!(generator::generate(dir).is_err());
Ok(())
}
+15
View File
@@ -0,0 +1,15 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_depth;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod fonts;
mod generated_docs;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod vector_drawable;
@@ -1,5 +1,4 @@
//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
@@ -1,5 +1,4 @@
//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
@@ -0,0 +1,41 @@
//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end.
use std::process::Command;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
}
#[test]
fn xml_export_writes_a_drawable_and_prints_its_notes() {
let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("s.jsonl");
std::fs::write(
&script,
r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##,
)
.unwrap();
let file = d.join("doc.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let xml = d.join("icon.xml");
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let text = std::fs::read_to_string(&xml).unwrap();
assert!(text.contains("<vector") && text.contains("android:fillColor=\"#FF0000\""), "{text}");
assert!(String::from_utf8_lossy(&out.stderr).contains("note: drop shadows were left out (Dot)"));
// --format xml into a folder; --vd-compat ignore prints nothing.
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--format", "xml", "--vd-compat", "ignore"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(dir.join("Dot.xml").exists());
assert!(!String::from_utf8_lossy(&out.stderr).contains("note:"));
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).args(["--vd-compat", "sloppy"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("--vd-compat"));
let _ = std::fs::remove_dir_all(&d);
}
+19 -7
View File
@@ -1,5 +1,6 @@
//! 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
//! (only on a server open to the public), and reports, downloads and requests that change the
//! server's files (always, with the server key too). 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
@@ -26,7 +27,9 @@ pub const MAX_ADDRESSES: usize = 10_000;
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).
/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once). Larger
/// than the burst, a library or plugin is served once the address's budget is full, which it then
/// owes ([`Bucket::take_spread`]): it comes more slowly, never not at all.
pub const DOWNLOAD_RATE: f64 = (1u64 << 20) as f64;
pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
@@ -34,6 +37,10 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
/// 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;
/// Requests that change the server's files (join codes, links, renames, removals, blocks,
/// publishing with the key) one address may make a minute, with the server key or an owner token.
pub const ADMIN_PER_MINUTE: f64 = 60.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.
@@ -66,6 +73,8 @@ pub enum Action {
Download(usize),
/// Trying a join code.
Code,
/// A request that changes the server's files ([`ADMIN_PER_MINUTE`]).
Admin,
}
struct Address {
@@ -76,6 +85,7 @@ struct Address {
reports: Bucket,
downloads: Bucket,
codes: Bucket,
admin: Bucket,
/// Wrong join codes in a row, and when the last one was.
wrong: u32,
wrong_at: Instant,
@@ -120,15 +130,15 @@ impl Abuse {
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.
/// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong
/// ones before it (they only decay with time); enough wrong ones 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 {
if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
@@ -157,7 +167,7 @@ impl Abuse {
}
for &(k, times) in &keys {
let (b, n) = Self::pick(self.address(k, times, now), what);
b.take_n(now, n);
b.take_spread(now, n);
}
true
}
@@ -177,6 +187,7 @@ impl Abuse {
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),
admin: bucket(ADMIN_PER_MINUTE * times, 60.0),
wrong: 0,
wrong_at: now,
locked: None,
@@ -195,13 +206,14 @@ impl Abuse {
Action::Report => (&mut a.reports, 1.0),
Action::Download(n) => (&mut a.downloads, n as f64),
Action::Code => (&mut a.codes, 1.0),
Action::Admin => (&mut a.admin, 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) {
if b.take_spread(now, n) {
b.give(n);
return true;
}
@@ -0,0 +1,85 @@
//! Requests that change the server's files have a budget per address, with the server key or an
//! owner token too; join codes are found through an index of their hashes, with the same results
//! as comparing every link.
use std::net::IpAddr;
use figcraft_collab::message::{Admin, AdminReply};
use figcraft_collab_net::hub::Backend;
use crate::abuse::ADMIN_PER_MINUTE;
use crate::store::{BUSY_ADMIN, CODE_GONE, Store};
use crate::test_util::{TempDir, config, ip, snapshot};
fn rename(store: &mut Store, from: IpAddr, file: &str) -> AdminReply {
Backend::admin(store, from, Admin::Rename { file: file.into(), name: "Renamed".into() }, &|_| None).reply
}
#[test]
fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() {
let dir = TempDir::new("admin-budget");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap();
let flooder = ip("198.51.100.1");
let start = std::time::Instant::now();
let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count();
// The budget refills one request a second, so a slow run (a loaded test machine) lets a few more in.
let budget = ADMIN_PER_MINUTE as usize;
let refilled = start.elapsed().as_secs() as usize + 1;
assert!((budget..=budget + refilled).contains(&done), "a minute's worth, then wait: {done} done");
// Use up whatever refilled meanwhile; the request right after is refused.
for _ in 0..refilled {
if rename(&mut store, flooder, &file.id) != AdminReply::Done {
break;
}
}
assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() });
// An owner token's requests share the address's budget.
let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply;
assert_eq!(hosted, AdminReply::Failed { reason: BUSY_ADMIN.into() });
// Looking costs nothing, and another address has its own budget.
assert!(matches!(Backend::admin(&mut store, flooder, Admin::Files, &|_| None).reply, AdminReply::Files(_)));
assert_eq!(rename(&mut store, ip("198.51.100.2"), &file.id), AdminReply::Done);
}
#[test]
fn codes_are_found_through_the_index_as_before() {
let dir = TempDir::new("code-index");
let files_dir = dir.path().join("files");
let (files, codes) = {
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let mut files = Vec::new();
let mut codes = Vec::new();
for n in 0..12 {
let (file, edit, _view) = store.publish(&format!("File {n}"), &snapshot("File")).unwrap();
codes.push(store.set_code(&file.id, &edit.token).unwrap());
files.push((file.id, edit.token, edit.link.id));
}
(files, codes)
};
// A copy of a file's folder holds the same code (the last file by id wins, as before).
let copy = "zzzzzzzzzzzzzzzz";
std::fs::create_dir_all(files_dir.join(copy)).unwrap();
for f in std::fs::read_dir(files_dir.join(&files[0].0)).unwrap() {
let f = f.unwrap();
std::fs::copy(f.path(), files_dir.join(copy).join(f.file_name())).unwrap();
}
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let who = |n: usize| ip(&format!("192.0.2.{n}"));
for (n, ((id, token, _), code)) in files.iter().zip(&codes).enumerate().skip(1) {
assert_eq!(store.resolve_code(who(n), code).unwrap(), (id.clone(), token.clone()), "file {n}");
}
assert_eq!(store.resolve_code(who(100), &codes[0]).unwrap().0, copy);
// Revoking the copy's link leaves the original's code working; revoking that one ends it.
store.revoke(copy, &files[0].2).unwrap();
assert_eq!(store.resolve_code(who(101), &codes[0]).unwrap(), (files[0].0.clone(), files[0].1.clone()));
store.revoke(&files[0].0, &files[0].2).unwrap();
assert_eq!(store.resolve_code(who(102), &codes[0]).unwrap_err(), CODE_GONE);
// A new code replaces the old in the index; a removed file's code is gone.
let newer = store.set_code(&files[1].0, &files[1].1).unwrap();
assert_eq!(store.resolve_code(who(103), &codes[1]).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(who(104), &newer).unwrap().0, files[1].0);
store.remove(&files[2].0).unwrap();
assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0);
}
+8 -8
View File
@@ -29,7 +29,7 @@ Usage:
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 create FILE --access edit|comment|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)
@@ -43,8 +43,8 @@ Options (also keys of the --config TOML file):
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb, max_plugins,
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
max_connections, max_people, compact_after, compact_mb, max_log_mb,
max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish,
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
connects_per_minute, publishes_per_hour, messages_per_second
@@ -124,7 +124,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
"--listen" => config.listen = value("--listen")?,
"--data" => config.data = PathBuf::from(value("--data")?),
"--public-url" => config.public_url = Some(value("--public-url")?),
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit, comment or view")?),
"--label" => label = value("--label")?,
"--rotate" => rotate = true,
"--sort" => {
@@ -147,7 +147,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
["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 }
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit, comment or view")?, label }
}
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
["plugins"] | ["plugins", "list"] => Command::Plugins,
@@ -316,7 +316,8 @@ fn running(data: &Path) -> Result<SocketAddr, String> {
/// Do one request: through the running server, or on the data directory.
fn admin(cfg: &Config, req: Admin) -> Result<AdminReply, String> {
match Store::try_open(&cfg.data, cfg.store())? {
Some(mut store) => Ok(store.admin(req, &|_| None).reply),
// On this machine, one request per command: never over the address's budget.
Some(mut store) => Ok(store.admin(std::net::Ipv4Addr::LOCALHOST.into(), req, &|_| None).reply),
None => {
let addr = running(&cfg.data)?;
let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
@@ -360,8 +361,7 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
};
let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() };
let mut out = String::new();
let what = if l.link.access == Access::View { "view" } else { "edit" };
let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file);
let _ = writeln!(out, "New {} link {} for file {}:", l.link.access.id(), l.link.id, l.link.file);
let _ = writeln!(out, " {}", invite.link());
let _ = writeln!(out, " {}", invite.url());
let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token).");
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::store::Store;
use crate::test_util::{TempDir, config};
fn args(s: &[&str]) -> Vec<String> {
s.iter().map(|a| a.to_string()).collect()
s.iter().map(ToString::to_string).collect()
}
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
+28 -32
View File
@@ -7,11 +7,9 @@
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};
@@ -20,24 +18,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, W
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};
use crate::test_util::{Raw, TempDir, config, ip, live, pump, session, snapshot};
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}"))
}
@@ -64,6 +50,16 @@ fn is_code(shown: &str) -> bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
/// The links file keeps hashes only, never the codes or the token they wrap.
fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 3);
}
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
@@ -80,7 +76,8 @@ fn a_file_shared_online_is_joined_by_its_codes() {
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.
// Others get links of their own (edit, view and comment; the owner keeps the file's first
// ones): five 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> {
@@ -88,14 +85,9 @@ fn a_file_shared_online_is_joined_by_its_codes() {
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);
assert_eq!(before_stop.len(), 5);
// On disk: hashes only, of the codes as of the links' tokens.
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 2);
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
// Someone types the edit code, the way people type.
let mut b = app("Bo", &url);
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
@@ -151,7 +143,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
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);
assert_eq!(ids(dir.path()).len(), 5);
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.
@@ -262,17 +254,21 @@ 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.
// A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones.
let a = ip("2001:db8:1:2::1");
let b = ip("2001:db8:1:2::2");
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(a, false, t0);
}
abuse.code_tried(b, true, t0);
abuse.code_tried(a, true, t0);
assert_eq!(abuse.code_locked(a, t0), None);
abuse.code_tried(a, false, t0);
assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT));
// Wrong codes: locked for the lockout, then free again.
for _ in 0..WRONG_CODES {
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);
@@ -0,0 +1,145 @@
//! Comment links end to end over loopback: sharing online makes a comment link and code beside the
//! edit and view ones; someone who joins with it comments and everyone sees it, their app refuses
//! their other edits, and the server refuses them too for a client that skips the check; a client
//! that can't read comment links gets in as a viewer, and links listed to it read as view links.
use std::time::Duration;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
use figcraft_collab::{COMMENT_ONLY, Change, Client, Entry, Json};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Comment, Message};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// The welcome `raw` gets after saying hello with `client_version` through `link`.
fn welcome(raw: &mut Raw, link: &str, client_version: &str) -> Client {
let invite = Invite::parse(link).unwrap();
let ClientMsg::Hello(mut h) = Client::hello(&invite.file, &invite.token, "Raw", "") else { panic!("not a hello") };
h.client_version = client_version.into();
raw.send(&ClientMsg::Hello(h));
loop {
if let w @ ServerMsg::Welcome { .. } = raw.next() {
return Client::join(w).unwrap();
}
}
}
/// What the server says to the batch `raw` sends next.
fn answer(raw: &mut Raw) -> ServerMsg {
loop {
if let m @ (ServerMsg::Reject { .. } | ServerMsg::Changes(_)) = raw.next() {
return m;
}
}
}
#[test]
fn a_comment_link_comments_and_the_server_refuses_its_edits() {
let dir = TempDir::new("comment-link");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
ex(&mut a, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 80}));
let p = publish(&mut a, dir.path(), addr);
let file = Invite::parse(&p.edit).unwrap().file;
let endpoint = Endpoint::plain(addr.to_string());
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
let AdminReply::Link(made) = ask(Admin::CreateLink { file: file.clone(), access: Access::Comment, label: "Client".into() }) else { panic!() };
assert_eq!(made.link.access, Access::Comment);
let link = Invite { server: endpoint.clone(), file: file.clone(), token: made.token.clone() }.link();
// Cy joins with it and comments; Ada sees the thread.
let mut c = join(&link, "Cy");
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("comment"), &json!(true)));
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Bigger logo?"}))["id"].as_u64().unwrap();
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
ex(&mut a, "comment.reply", json!({"id": thread, "text": "On it"}));
pump(&mut [&mut a, &mut c], "the reply at Cy", |s| s[1].doc().unwrap().comment(thread).is_some_and(|t| t.replies.len() == 1));
ex(&mut c, "comment.resolve", json!({"id": thread}));
pump(&mut [&mut a, &mut c], "resolved at Ada", |s| s[0].doc().unwrap().comment(thread).is_some_and(|t| t.resolved));
// Cy's app refuses anything else before it is sent.
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
assert!(e.contains("Can comment"), "{e}");
// A client that skips the app's check: its comment goes through, its edit is refused.
let mut raw = Raw::connect(addr);
let mut client = welcome(&mut raw, &link, &figcraft_collab::message::client_version());
assert_eq!(client.access(), Access::Comment);
let mut d = client.document().clone();
let page = d.first_page().unwrap();
let id = d.new_comment_id();
d.comments.push(Comment {
id,
page,
x: 1.0,
y: 1.0,
anchor: None,
first: Message::new("Raw", "Hi", 0),
resolved: false,
replies: vec![],
read_by: None,
});
raw.send(&client.edit(&d).unwrap().unwrap());
let ServerMsg::Changes(acc) = answer(&mut raw) else { panic!("refused") };
client.receive(ServerMsg::Changes(acc), &figcraft_collab::NoRecompute).unwrap();
let mut d = client.document().clone();
d.node_mut(page).unwrap().name = "Sneaky".into();
raw.send(&client.edit(&d).unwrap().unwrap());
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
// So is a forged batch that hides an edit among comment changes.
let forged = Change::Put { entry: Entry::Comment(id), value: Json::of(&client.document().comments[0]).unwrap() };
let rename = Change::SetDoc { prop: figcraft_collab::DocProp::Name, value: Json::of("Sneaky").unwrap() };
raw.send(&ClientMsg::Changes { base_seq: 0, batch_id: 99, changes: vec![forged, rename] });
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
pump(&mut [&mut a], "the raw comment, nothing else", |s| {
let d = s[0].doc().unwrap();
d.comment(id).is_some() && d.node(page).is_some_and(|n| n.name != "Sneaky") && d.name != "Sneaky"
});
// A client from before comment links gets in as a viewer.
let mut old = Raw::connect(addr);
assert_eq!(welcome(&mut old, &link, "0.12.1 signals hosting").access(), Access::View);
// And lists the link as a view link.
let mut m = Raw::connect(addr);
m.send(&ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key(dir.path()), client_version: "0.12.1".into() }));
assert!(matches!(m.next(), ServerMsg::Admin(r) if *r == AdminReply::Ready));
m.send(&ClientMsg::Admin(Admin::Links { file: file.clone() }));
let ServerMsg::Admin(r) = m.next() else { panic!("no listing") };
let AdminReply::Links(links) = *r else { panic!("{r:?}") };
assert!(links.iter().all(|l| l.access != Access::Comment) && links.iter().any(|l| l.id == made.link.id && l.access == Access::View));
let AdminReply::Links(links) = ask(Admin::Links { file }) else { panic!() };
assert!(links.iter().any(|l| l.id == made.link.id && l.access == Access::Comment), "this build reads it");
server.stop();
}
#[test]
fn sharing_online_offers_a_comment_code() {
let dir = TempDir::new("comment-code");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/?key={}", server.addr(), key(dir.path()));
let mut a = session("Ada");
ex(&mut a, "collab.share", json!({"server": url}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live_summary()["share"]["shared"] == true);
let shared = a.live_summary()["share"].clone();
let comment = shared["commentCode"].as_str().unwrap().to_string();
assert!(comment != shared["editCode"] && comment != shared["viewCode"], "{shared}");
assert_eq!(ex(&mut a, "collab.share", json!({"access": "comment"}))["code"], comment.as_str());
// Someone types it: they can comment, not edit.
let mut c = session("Cy");
ex(&mut c, "pref.publishServer", json!({"url": format!("ws://{}/", server.addr())}));
ex(&mut c, "collab.join", json!({"code": comment}));
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(c.live_summary()["access"], "comment");
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Looks good"}))["id"].as_u64().unwrap();
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
assert!(c.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
// Stopping revokes it with the others: Cy is out.
ex(&mut a, "collab.stop", json!({}));
pump(&mut [&mut a, &mut c], "Cy out", |s| !s[1].live().status.active() && s[0].live().peers.is_empty());
server.stop();
}
+27 -7
View File
@@ -39,6 +39,9 @@ pub struct Config {
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
/// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one
/// further behind catches up with the whole file).
pub max_log_mb: u64,
/// Most plugin versions the plugin directory keeps (0 turns it off).
pub max_plugins: usize,
/// Largest plugin bundle, in MiB.
@@ -84,6 +87,7 @@ impl Default for Config {
max_people: 256,
compact_after: 1000,
compact_mb: 16,
max_log_mb: 64,
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
@@ -143,6 +147,14 @@ impl Config {
client_max - 1
));
}
if self.people() < self.max_people {
out.push(format!(
"max_people is {}: the app keeps track of at most {} people in a file, so {} are let in",
self.max_people,
self.people(),
self.people()
));
}
if !self.open_publish {
return out;
}
@@ -167,6 +179,12 @@ impl Config {
out
}
/// Most people in one file at once: `max_people`, lowered to what the app keeps track of
/// ([`figcraft_collab::message::MAX_PEERS`]).
pub fn people(&self) -> usize {
self.max_people.min(figcraft_collab::message::MAX_PEERS)
}
/// 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) };
@@ -209,6 +227,7 @@ impl Config {
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_log_mb", self.max_log_mb),
("max_plugin_mb", self.max_plugin_mb),
("max_plugins_mb", self.max_plugins_mb),
] {
@@ -216,7 +235,7 @@ impl Config {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.file_mb() > 4096 || self.compact_mb > 4096 {
if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
// A bundle travels in one message (at most 64 MiB) next to its entry.
@@ -274,17 +293,18 @@ impl Config {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
/// The collaboration core's part: when open, messages as large as a file at most, and the
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
/// quarter of a file's bytes (256 KiB at least).
/// The collaboration core's part: the batches kept for reconnecting clients (in memory, and
/// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at
/// most, and those batches also at most a quarter of a file's bytes (256 KiB at least).
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX);
let base = figcraft_collab::Limits { max_peers: self.people(), max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
if !self.open_publish {
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base }
}
/// The transport's part. A private server keeps the defaults; open to the public, connections
+32 -5
View File
@@ -13,6 +13,7 @@
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
//! first damaged record (a write cut short).
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
@@ -49,7 +50,7 @@ pub fn free_bytes(path: &Path) -> Option<u64> {
/// `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();
let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
@@ -77,6 +78,7 @@ fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
#[cfg(not(unix))]
let _ = private;
let mut f = o.open(&tmp)?;
counted(true);
f.write_all(bytes)?;
f.sync_all()?;
}
@@ -100,6 +102,7 @@ pub fn sync_dir(dir: Option<&Path>) {
/// Read a file of at most `max` bytes (a longer one is refused without being read).
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?;
counted(false);
let len = f.metadata().map_err(|e| e.to_string())?.len();
if len > max {
return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len));
@@ -109,6 +112,29 @@ pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
Ok(bytes)
}
#[cfg(test)]
thread_local! {
/// Files read and written on this thread (tests: what an action costs, #1074).
static IO: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) };
}
/// Count a file read or write (a log append counts as a write).
pub(crate) fn counted(write: bool) {
#[cfg(test)]
IO.with(|io| {
let (r, w) = io.get();
io.set(if write { (r, w + 1) } else { (r + 1, w) });
});
#[cfg(not(test))]
let _ = write;
}
/// The files read and written on this thread since the last call: `(reads, writes)`.
#[cfg(test)]
pub(crate) fn take_io() -> (u32, u32) {
IO.with(|io| io.replace((0, 0)))
}
fn name(path: &Path) -> String {
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}
@@ -127,13 +153,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
let body = message::encode(state).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
let mut out = Vec::with_capacity(SNAP_HEADER);
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&0u32.to_le_bytes());
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
let sum = crc(&out[SNAP_HEADER..]);
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
Ok(out)
}
@@ -0,0 +1,67 @@
//! A team library or plugin larger than one address's download burst (16 MiB) is served once the
//! address's budget is full, which it then owes: it comes more slowly, never not at all, and the
//! address still gets no more than its rate over time (#1089).
use std::time::{Duration, Instant};
use figcraft_collab::message::{Access, LibraryReply, LibraryReq};
use figcraft_collab_net::hub::Backend;
use figcraft_collab_net::net::Bucket;
use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST, DOWNLOAD_RATE};
use crate::hardening_tests::archive;
use crate::store::{BUSY_DOWNLOADS, Store};
use crate::test_util::{TempDir, config, ip, snapshot};
const MIB: usize = 1 << 20;
fn secs(s: u64) -> Duration {
Duration::from_secs(s)
}
#[test]
fn a_bucket_lends_past_its_burst_only_when_full_and_is_paid_back_at_its_rate() {
let mut b = Bucket::new(1.0, 4.0);
let t = Instant::now();
assert!(b.take_n(t, 1.0));
assert!(!b.take_spread(t, 10.0), "not full: no loan");
assert!(b.take_spread(t + secs(1), 10.0), "full again: lent, owing 6");
assert!(!b.take_spread(t + secs(6), 1.0), "still owing 1");
assert!(!b.take_spread(t + secs(10), 10.0), "3 of 4 back");
assert!(b.take_spread(t + secs(11), 10.0), "paid back: lent again");
assert!(b.take_spread(t + secs(21), 2.0), "within the burst as before");
}
#[test]
fn large_downloads_keep_an_address_to_its_rate() {
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 big = 40 * MIB;
// Asking for 40 MiB every second for ten minutes.
let mut got = Vec::new();
for s in 0..=600 {
if a.allow(me, Action::Download(big), t + secs(s)) {
got.push(s);
}
}
let bytes = |within: u64| (got.iter().filter(|s| **s < within).count() * big) as f64;
for minutes in 1..=10 {
let most = big as f64 + DOWNLOAD_RATE * (minutes * 60) as f64;
assert!(bytes(minutes * 60) <= most, "{} MiB in {minutes} min", bytes(minutes * 60) / MIB as f64);
}
assert!(bytes(601) >= DOWNLOAD_RATE * 600.0, "it is served at the rate, not refused: {got:?}");
assert!(big as f64 > DOWNLOAD_BURST);
}
#[test]
fn a_library_larger_than_the_burst_is_downloaded_then_the_address_waits() {
let dir = TempDir::new("biglib");
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, 40 * MIB)).unwrap();
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Library { archive, .. } if archive.len() > 40 * MIB));
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Failed { reason } if reason == BUSY_DOWNLOADS), "then it waits");
assert!(matches!(fetch(&mut store, "198.51.100.9"), LibraryReply::Library { .. }), "another address has its own share");
}
+24 -14
View File
@@ -25,27 +25,15 @@ 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};
use crate::test_util::{Raw, TempDir, config, ip, snapshot};
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> {
pub(crate) 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();
@@ -838,3 +826,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() {
assert_eq!(served(fetch(&mut store)), (2, v2));
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
}
#[test]
fn a_library_publish_cannot_claim_the_highest_version() {
let dir = TempDir::new("libmax");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
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 mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone();
let mut archive = |version: u64| {
lib.manifest.version = version;
figcraft_engine::library_server::archive(&lib).unwrap()
};
let high = 1u64 << 32;
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version");
assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in");
assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it");
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused");
assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in");
}
+162
View File
@@ -0,0 +1,162 @@
//! Joining and leaving a file nobody is editing costs no disk I/O while it is kept loaded
//! (#1074), and what changes is still saved as before.
use std::fs;
use figcraft_collab::message::{Access, ClientMsg, Hello};
use figcraft_collab::{Client, NoRecompute, Outgoing, Replica, Server};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::Document;
use crate::disk::{self, META};
use crate::store::{FILES, Store, StoreConfig};
use crate::test_util::{TempDir, config};
fn hello() -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
h
}
/// Let `hello` into `server` and hand its messages to `client` (a new one when `None`); the
/// client and its id there.
fn enter(server: &mut Server, client: Option<Client>, hello: Hello) -> (Client, u32) {
let (id, out) = server.join_as(hello, Access::Edit).unwrap();
let mut msgs = out.into_iter().filter_map(|o| match o {
Outgoing::To(to, m) if to == id => Some(m),
_ => None,
});
let client = match client {
Some(mut c) => {
for m in msgs {
c.receive(m, &NoRecompute).unwrap();
}
c
}
None => Client::join(msgs.next().unwrap()).unwrap(),
};
(client, id)
}
/// A file opened, joined and left once: the store, the file's id and the client, ready to resume.
fn published(dir: &TempDir) -> (Store, String, Client, StoreConfig) {
let c = StoreConfig { compact_after: 1000, ..config(dir.path()).store() };
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let d = Document::new("Idle");
let (info, _, _) = store.publish("Idle", &Replica::new(d).snapshot().unwrap()).unwrap();
let id = info.id;
let mut server = store.open(&id).unwrap();
let (client, cid) = enter(&mut server, None, hello());
server.leave(cid);
store.changed(&id, &mut server);
store.flush();
assert!(store.idle(&id, &mut server));
(store, id, client, c)
}
/// One rejoin and leave, as the hub drives it; `edit` renames the page on the way.
fn cycle(store: &mut Store, id: &str, client: Client, edit: Option<&str>) -> Client {
let mut server = store.open(id).unwrap();
let (mut client, cid) = {
let mut c = client;
let ClientMsg::Hello(h) = c.reconnect(hello()) else { panic!() };
enter(&mut server, Some(c), h)
};
if let Some(name) = edit {
let mut d = client.document().clone();
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().name = name.into();
let m = client.edit(&d).unwrap().unwrap();
for o in server.receive(cid, m) {
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
client.receive(m, &NoRecompute).unwrap();
}
}
}
server.leave(cid);
store.changed(id, &mut server);
store.flush();
assert!(store.idle(id, &mut server));
client
}
fn page_name(server: &Server) -> String {
let d = server.replica().document();
d.first_page().and_then(|p| d.node(p)).map(|n| n.name.clone()).unwrap_or_default()
}
#[test]
fn rejoining_an_unchanged_file_reads_and_writes_nothing() {
let dir = TempDir::new("idle-io");
let (mut store, id, mut client, _) = published(&dir);
let log = dir.path().join(FILES).join(&id).join(crate::disk::LOG);
let log_len = fs::metadata(&log).unwrap().len();
disk::take_io();
for _ in 0..20 {
client = cycle(&mut store, &id, client, None);
}
assert_eq!(disk::take_io(), (0, 0), "20 join/leave cycles: no file read or written");
assert_eq!(fs::metadata(&log).unwrap().len(), log_len);
assert_eq!(store.compactions(), 0);
}
#[test]
fn edits_while_kept_loaded_are_still_saved() {
let dir = TempDir::new("idle-edits");
let (mut store, id, mut client, c) = published(&dir);
for i in 0..3 {
client = cycle(&mut store, &id, client, Some(&format!("Page {i}")));
}
// A cycle with an edit compacts it on leaving, as before; the next one does nothing again.
assert!(store.compactions() > 0);
disk::take_io();
let _ = cycle(&mut store, &id, client, None);
assert_eq!(disk::take_io(), (0, 0));
// Saved: another store (a restart) reads it back.
drop(store);
let mut again = Store::open(dir.path(), c).unwrap();
disk::take_io();
let server = again.open(&id).unwrap();
assert_eq!(page_name(&server), "Page 2");
assert_eq!(disk::take_io().0, 2, "one load: the snapshot and the log");
}
#[test]
fn the_file_kept_loaded_longest_lets_go_first() {
let dir = TempDir::new("idle-many");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let ids: Vec<String> =
(0..17).map(|i| store.publish(&format!("F{i}"), &Replica::new(Document::new("F")).snapshot().unwrap()).unwrap().0.id).collect();
for id in &ids {
let mut server = store.open(id).unwrap();
assert!(store.idle(id, &mut server));
}
disk::take_io();
let _ = store.open(&ids[16]).unwrap();
assert_eq!(disk::take_io(), (0, 0), "the latest is still loaded");
let _ = store.open(&ids[0]).unwrap();
assert_eq!(disk::take_io(), (2, 0), "past 16, the first let go: read again");
}
#[test]
fn the_open_time_is_written_hourly_and_when_the_server_stops() {
let dir = TempDir::new("idle-opened");
let (mut store, id, client, _) = published(&dir);
let opened = || -> u64 {
let meta: serde_json::Value = serde_json::from_slice(&fs::read(dir.path().join(FILES).join(&id).join(META)).unwrap()).unwrap();
meta["opened"].as_u64().unwrap()
};
// Last written half an hour ago: a join doesn't write it yet; stopping does.
store.age(&id, 1800);
let before = opened();
disk::take_io();
let client = cycle(&mut store, &id, client, None);
assert_eq!(disk::take_io(), (0, 0));
assert_eq!(opened(), before);
store.stop(Vec::new());
assert!(opened() >= before + 1800);
// Written over an hour ago: the next join writes it.
store.age(&id, 4000);
disk::take_io();
let _ = cycle(&mut store, &id, client, None);
assert_eq!(disk::take_io(), (0, 1));
}
+8
View File
@@ -86,6 +86,8 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
Ok(Running { hosting })
}
#[cfg(test)]
mod admin_budget_tests;
#[cfg(test)]
mod background_tests;
#[cfg(test)]
@@ -93,10 +95,16 @@ mod cli_tests;
#[cfg(test)]
mod codes_tests;
#[cfg(test)]
mod comment_link_tests;
#[cfg(test)]
mod directory_tests;
#[cfg(test)]
mod download_burst_tests;
#[cfg(test)]
mod hardening_tests;
#[cfg(test)]
mod idle_io_tests;
#[cfg(test)]
mod library_tests;
#[cfg(test)]
mod owned_tests;
+31 -27
View File
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
}
}
/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
let mut raw = Raw::connect(addr);
raw.send(&open(edit));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
let mut rng = Rng::new(5);
for round in 0..12 {
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
for _ in 0..1 + rng.below(8) {
let i = rng.below(junk.len());
junk[i] = rng.next_u64() as u8;
}
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
}
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
let mut raw = Raw::connect(addr);
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
}
#[test]
fn publish_enable_update_and_usage() {
let dir = TempDir::new("library");
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
let edit = Invite::parse(&p.edit).unwrap();
let mut raw = Raw::connect(addr);
raw.send(&open(&edit));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
let mut rng = Rng::new(5);
for round in 0..12 {
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
for _ in 0..1 + rng.below(8) {
let i = rng.below(junk.len());
junk[i] = rng.next_u64() as u8;
}
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
}
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
let mut raw = Raw::connect(addr);
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
junk_is_refused(addr, &edit);
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
// Kept on disk: the library is there after the server stops.
+1 -1
View File
@@ -158,7 +158,7 @@ fn only_a_files_own_token_learns_about_it() {
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_eq!(first.links.len(), 5, "the owner's two links and the three 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.
+95 -15
View File
@@ -6,10 +6,8 @@ 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};
@@ -18,24 +16,12 @@ 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};
use crate::test_util::{Raw, TempDir, config, ip, join, key, live, pump, session, snapshot};
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}"))
}
@@ -164,6 +150,64 @@ fn caps_per_address_and_for_everyone() {
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
}
#[test]
fn caps_per_address_count_the_wider_ipv6_blocks() {
let dir = TempDir::new("block-caps");
let mut c = open(dir.path());
c.max_files_per_ip = 1;
c.publishes_per_hour = 1000;
let mut store = Store::open(dir.path(), c.store()).unwrap();
// A /48 keeps 4 times one address's files: /64s in different /56s of it.
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
// A /56 keeps twice one address's files.
store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap();
store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap();
let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
drop(store);
// Bytes: one address keeps one file's worth, so a /48 keeps 4 files.
let probe = TempDir::new("block-caps-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-caps-bytes");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
s.hosting.max_bytes_per_ip = size;
let mut store = Store::open(dir.path(), s).unwrap();
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err();
assert!(e.contains("MiB it may keep"), "{e}");
}
#[test]
fn edits_count_the_wider_ipv6_blocks_too() {
use figcraft_collab_net::hub::Backend;
let probe = TempDir::new("block-room-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-room");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
// One address keeps 2.25 files' worth, so its /56 keeps 4.5.
s.hosting.max_bytes_per_ip = size * 2 + size / 4;
let mut store = Store::open(dir.path(), s).unwrap();
let mut ids = Vec::new();
for i in 0..4 {
ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id);
}
let server = Backend::open(&mut store, &ids[0]).unwrap();
// Its own /64 has room for this, but its /56 doesn't.
let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err();
assert!(e.contains("have reached their"), "{e}");
Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap();
}
#[test]
fn idle_files_published_without_the_key_expire() {
let dir = TempDir::new("expiry");
@@ -196,6 +240,22 @@ fn idle_files_published_without_the_key_expire() {
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
}
#[test]
fn offline_commands_do_not_sweep_idle_files() {
let dir = TempDir::new("offline-sweep");
let mut c = open(dir.path());
c.idle_days = 30.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();
store.age(&public.id, 31 * 86_400);
drop(store);
let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap();
drop(store);
assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file");
let _serving = Store::open(dir.path(), c.store()).unwrap();
assert!(!dir.path().join("files").join(&public.id).exists());
}
#[test]
fn rate_limits_and_blocks_refuse_with_a_reason() {
let dir = TempDir::new("limits");
@@ -421,6 +481,13 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
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());
// Clients keep track of at most `MAX_PEERS` others (#1121).
let people = figcraft_collab::message::MAX_PEERS;
let many = Config { max_people: people + 1, ..set.clone() };
assert!(many.check().is_ok(), "a server configured for more still starts");
assert_eq!(many.people(), people, "with that many");
assert!(many.warnings().iter().any(|w| w.contains("max_people is 4097") && w.contains("4096 are let in")), "{:?}", many.warnings());
assert_eq!(Config { max_people: people, ..set.clone() }.people(), people);
assert!(Config { idle_days: f64::NAN, ..set }.check().is_err());
}
@@ -510,6 +577,19 @@ fn files_above_64_mib_fit_every_limit() {
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() {
let log = |cfg: Config| cfg.store().core.max_log_bytes;
assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too");
assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20);
// Open to the public: also at most a quarter of a file (16 MiB unless set).
assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20);
assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20);
for mb in [0, 4097] {
assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}");
}
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
+3 -1
View File
@@ -261,7 +261,9 @@ fn fuzzed_frames_dont_hurt_the_server() {
break;
}
}
let _ = ws.read();
// read() returns Result<Message>, not a lock guard; the fuzz loop ends here on purpose and
// the close frame from the server is not interesting.
let _ = ws.read(); // NOSONAR
}
// Still serving, the file intact.
let mut b = join(&p.edit, "Bo");
File diff suppressed because it is too large. Load diff
+270
View File
@@ -0,0 +1,270 @@
use super::journal::flush_log;
use super::{
Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr,
LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret,
};
/// Whether `req` changes the server's files (a write, a removal or a join code's stretching):
/// such requests count against the address's budget ([`Action::Admin`]); looking doesn't.
fn changes_files(req: &Admin) -> bool {
!matches!(
req,
Admin::Files | Admin::Links { .. } | Admin::Listing | Admin::Reports | Admin::Blocked | Admin::Owned { .. } | Admin::RemovePeer { .. }
)
}
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
let entry = self.entry(file)?;
let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
}
fn open(&mut self, file: &str) -> Result<Server, String> {
self.load(file).map_err(|e| {
let why = format!("file {file} can't be opened: {e}");
log::error!("{why}");
self.note(why);
"this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
})
}
fn changed(&mut self, file: &str, server: &mut Server) {
let journal = server.take_journal();
if journal.is_empty() {
return;
}
let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
let Some(e) = self.files.get_mut(file) else { return };
let Some(log) = e.log.as_mut() else { return };
for j in &journal {
match disk::record(j) {
Ok(r) => log.pending.extend(r),
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
}
}
log.entries += journal.len();
log.dirty = true;
e.meta.updated = now();
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
// The snapshot holds what's pending too.
self.compact(file, server);
}
}
fn flush(&mut self) -> Vec<String> {
self.flush_all()
}
fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> {
self.room_for(file, server, bytes as u64)
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let mut synced = false;
let compact = self.files.get_mut(file).is_some_and(|entry| {
let snapshot_seq = entry.meta.seq;
entry.log.as_mut().is_some_and(|log| {
// Issued client ids and refusals are durable in the log; only edits need a
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
synced = flush_log(file, log).is_ok();
!synced || (log.dirty && server.seq() > snapshot_seq)
})
});
if compact {
self.compact(file, server);
}
// It closes anyway, with its state in the snapshot and durable log.
self.cut_short.retain(|f| f != file);
let log = self.files.get_mut(file).and_then(|e| e.log.take());
// Kept loaded a while, when its log was sound: a join soon after reads and rewrites
// nothing (#1074). The hub drops its room as this returns: the session moves here.
if let Some(log) = log.filter(|l| synced && l.pending.is_empty()) {
let left = std::mem::replace(server, Server::new(figcraft_collab::Document::new(""), "", self.cfg.core));
self.park(file, left, log);
}
// Everyone just left: idle time counts from now.
self.touch(file);
true
}
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
self.write_usage(true);
self.flush_all();
// The open times not written yet (they are written at most hourly while files are used).
let unwritten: Vec<String> = self.files.iter().filter(|(_, e)| e.meta.opened > e.opened_written).map(|(id, _)| id.clone()).collect();
for file in unwritten {
self.write_opened(&file);
}
for (file, server) in open {
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
self.compact(file, server);
}
}
}
fn manage(&mut self, key: &str) -> Result<(), String> {
// Both sides are SHA-256 digests, so the comparison sees equal lengths whatever was sent.
match same_secret(&hash(key), &hash(&self.key)) {
true => Ok(()),
false if self.cfg.hosting.open => {
Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into())
}
false => Err("wrong server key".into()),
}
}
fn connect(&mut self, ip: IpAddr) -> Result<(), String> {
if now() >= self.next_sweep {
self.sweep();
self.write_usage(false);
}
if self.blocked.contains(&ip) {
return Err("this server doesn't accept connections from your address".into());
}
if !self.abuse.allow(ip, Action::Connect, Instant::now()) {
return Err("too many connections from your address lately: wait a minute and try again".into());
}
Ok(())
}
fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool {
// Cursors and cursor chat (many people behind one address each send ~30 a second while
// they move) have a budget of their own, beside everything else.
let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message };
self.abuse.allow(ip, what, Instant::now())
}
fn hosting(&mut self, file: &str) -> Option<HostInfo> {
Some(self.host_info(Some(file)))
}
fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result<HostInfo, String> {
if file.is_empty() && owner.is_empty() {
return Ok(self.host_info(None));
}
if !self.owns(file, owner) {
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
}
Ok(self.host_info(Some(file)))
}
fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() });
let file = match &req {
Admin::Publish { name, snapshot } => {
return match self.publish_open(ip, name, snapshot) {
Ok((file, edit, view, owner)) => {
let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta));
AdminReply::Hosted { file, edit, view, owner, expires }.into()
}
Err(e) => {
log::info!("event=publish_refused ip={ip} reason={e:?}");
Handled::from(AdminReply::Failed { reason: e })
}
};
}
Admin::Owned { files } => return self.owned(files).into(),
Admin::Links { file }
| Admin::CreateLink { file, .. }
| Admin::Revoke { file, .. }
| Admin::Rename { file, .. }
| Admin::Code { file, .. }
| Admin::Remove { file } => file,
_ => return failed("that needs the server key"),
};
if owns != Some(file.as_str()) {
return failed("an owner token manages only its own file");
}
self.admin(ip, req, open)
}
fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
self.keep_report(file, link, ip, reason)
}
fn admin(&mut self, ip: IpAddr, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, Instant::now()) {
log::info!("event=refused ip={ip} reason=admin_per_address");
return failed(BUSY_ADMIN.into());
}
match req {
Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
Err(e) => failed(e),
},
Admin::Files => AdminReply::Files(self.list(open)).into(),
Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
Admin::Revoke { file, link } => match self.revoke(&file, &link) {
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None },
Err(e) => failed(e),
},
Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()),
Admin::Listing => AdminReply::Listing(self.listing(open)).into(),
Admin::Reports => AdminReply::Reports(self.reports.clone()).into(),
Admin::Block { ip } => match self.block(&ip, true) {
Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) },
Err(e) => failed(e),
},
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
Admin::Owned { files } => self.owned(&files).into(),
// The hub removes people itself (it knows who is connected); it never asks the store.
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
Admin::Remove { file } => {
if !self.files.contains_key(&file) {
return failed("this file isn't on the server (it may have been removed)".into());
}
let reply = match self.remove(&file) {
Ok(()) => AdminReply::Done,
Err(reason) => AdminReply::Failed { reason },
};
Handled { reply, revoked: None, removed: Some(file), blocked: None }
}
}
}
fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
let failed = |reason: String| LibraryReply::Failed { reason };
match req {
LibraryReq::Fetch { have, usage } => {
if let Some(u) = usage
&& let Err(e) = self.report_usage(file, u)
{
log::warn!("file {file}: a library usage report couldn't be kept: {e}");
}
self.fetch_library(ip, file, have).unwrap_or_else(failed)
}
LibraryReq::Publish { .. } if access != Access::Edit => {
failed("you can only view this file: publishing its library needs an edit link".into())
}
LibraryReq::Publish { archive } => {
self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version })
}
LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage),
}
}
fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply {
// One address's share first (before everyone's budget is spent or anything is read).
if let PluginReq::Fetch { id, version } = &req
&& let Some(size) = self.directory.size_of(id, version.as_deref())
&& !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now())
{
return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() };
}
self.directory.handle(publisher, req)
}
fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
self.resolve_code(ip, code)
}
fn closing(&self) -> Closing {
Closing::Restarting
}
}
+362
View File
@@ -0,0 +1,362 @@
use super::{
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
MAX_BLOCKED, MAX_OWNED, MAX_PARKED, MAX_PARKED_BYTES, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, PARK_FOR, Parked, REPORTS, Replica,
ReportInfo, SNAPSHOT, SWEEP_EVERY, Server, Store, TOUCH_EVERY, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now,
same_secret, token, write_json,
};
impl Store {
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
}
/// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
/// caps it gets, and the files and bytes kept by the files published from addresses in it. An
/// address that doesn't parse counts as itself only, ×1, by its exact text.
pub(super) fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
let blocks_of = |text: &str| text.parse::<IpAddr>().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
let kept: Vec<(&str, Vec<_>, u64)> = self
.files
.iter()
.filter_map(|(id, e)| {
let from = e.meta.ip.as_deref()?;
Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
})
.collect();
let sum = |mine: &mut dyn Iterator<Item = &(&str, Vec<_>, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
let blocks = blocks_of(ip);
if blocks.is_empty() {
let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
return vec![(1, files, bytes)];
}
blocks
.into_iter()
.map(|(block, times)| {
let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
(times, files, bytes)
})
.collect()
}
/// Keep a document published without the key from `ip`, within the caps on what one address
/// and everyone may keep: the file, its links and its owner token.
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
let h = &self.cfg.hosting;
let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes);
if !open {
return Err(KEY_ONLY.into());
}
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
return Err("too many files published from your address lately: try again in an hour".into());
}
let blocks = self.per_block(&ip.to_string());
for &(times, files, _) in &blocks {
if files >= max_files.saturating_mul(times as usize) {
return Err(format!(
"your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
));
}
}
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
let fits = |n: u64| {
for &(times, _, used) in &blocks {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
}
}
if used_all.saturating_add(n) > max_all {
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
}
Ok(())
};
let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?;
Ok((info, edit, view, owner.unwrap_or_default()))
}
/// Keep a document as a new file: published without the key from `ip` (it gets an owner
/// token), else by the operator. `fits` checks its size against the caps that apply.
fn keep(
&mut self,
name: &str,
snapshot: &[Change],
ip: Option<IpAddr>,
fits: &dyn Fn(u64) -> Result<(), String>,
) -> Result<(FileInfo, NewLink, NewLink, Option<String>), String> {
if self.files.len() >= self.cfg.max_files {
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
}
if let Some(why) = self.low_disk() {
return Err(why);
}
let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
if replica.document().node_count() > self.cfg.core.max_nodes {
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
}
let mut doc = replica.document().clone();
let name = match clean_name(name) {
n if n.is_empty() => doc.name.clone(),
n => n,
};
doc.name = name.clone();
let server = Server::new(doc, "", self.cfg.core);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(1, &state)?;
if bytes.len() as u64 > self.cfg.max_file_bytes {
return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
}
fits(bytes.len() as u64)?;
let id = loop {
let id = token::file_id()?;
if !self.files.contains_key(&id) && !self.dir(&id).exists() {
break id;
}
};
let t = now();
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
let owner = ip.map(|_| token::secret()).transpose()?;
let meta =
Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 };
let dir = self.dir(&id);
let written = fs::create_dir_all(&dir)
.map_err(|e| e.to_string())
.and_then(|()| {
disk::sync_dir(Some(&self.root.join(FILES)));
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())
})
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
// Last: a folder without its description is an upload cut short.
.and_then(|()| write_json(&dir.join(META), &meta));
if let Err(e) = written {
let _ = fs::remove_dir_all(&dir);
return Err(format!("couldn't keep the file: {e}"));
}
let opened_written = meta.opened;
let (links, bytes_kept) = (vec![edit.clone(), view.clone()], (bytes.len() + LOG_HEADER) as u64);
let entry = Entry { meta, links, log: None, bytes: bytes_kept, forced: 0, parked: None, opened_written };
self.files.insert(id.clone(), entry);
let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
match ip {
Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()),
None => log::info!("event=published file={id} bytes={} by=key", bytes.len()),
}
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner))
}
/// The files kept, with what the operator moderates by.
pub fn listing(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileRow> {
self.files
.iter()
.map(|(id, e)| FileRow {
info: self.info(id, e, open(id)),
opened: e.meta.opened,
expires: self.expires(&e.meta),
open: e.meta.owner.is_some(),
ip: e.meta.ip.clone().unwrap_or_default(),
reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
})
.collect()
}
/// Owner token `owner` manages file `file` (one published without the key).
pub(super) fn owns(&self, file: &str, owner: &str) -> bool {
let presented = hash(owner);
self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
}
/// What became of the files these `(file id, owner token)` pairs own: each one's name, links
/// and expiry, or `None` when that token manages no file of that id here (which doesn't say
/// whether the id exists). Asking opens nothing: it doesn't count as using a file.
pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
if files.len() > MAX_OWNED {
return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
}
let one = |(file, owner): &(String, String)| {
let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
};
AdminReply::Owned(files.iter().map(one).collect())
}
/// Rename a file (its listing; the document inside keeps its own name).
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
let name = clean_name(name);
if name.is_empty() {
return Err("give the file a name".into());
}
let path = self.dir(file).join(META);
let entry = self.entry(file)?;
let before = std::mem::replace(&mut entry.meta.name, name);
if let Err(e) = write_json(&path, &entry.meta) {
entry.meta.name = before;
return Err(format!("couldn't rename the file: {e}"));
}
Ok(())
}
/// Delete the files published without the key that nobody opened for the idle time (not
/// those open now); their ids.
pub fn sweep(&mut self) -> Vec<String> {
let t = now();
self.next_sweep = t.saturating_add(SWEEP_EVERY);
// Files kept loaded after everyone left let go once their time is up.
for e in self.files.values_mut() {
if e.parked.as_ref().is_some_and(|p| p.at.elapsed() > PARK_FOR) {
e.parked = None;
}
}
let due: Vec<String> =
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
for id in &due {
match self.remove(id) {
Ok(()) => log::info!("event=expired file={id}"),
Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
}
}
due
}
/// Make a file look unused for `secs` more (tests: expiry without waiting).
#[cfg(test)]
pub fn age(&mut self, file: &str, secs: u64) {
let path = self.dir(file).join(META);
if let Some(e) = self.files.get_mut(file) {
let m = &mut e.meta;
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
e.opened_written = m.opened;
let _ = write_json(&path, m);
}
}
/// Make an open file's log refuse writes (tests: a full or failing disk).
#[cfg(test)]
pub fn break_log(&mut self, file: &str) {
let path = self.dir(file).join(LOG);
if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
&& let Ok(f) = std::fs::File::open(path)
{
log.file = f;
}
}
/// Note that a file was opened now (it is used: it doesn't expire). Expiry counts in days, so
/// `meta.json` gets the time at most every [`TOUCH_EVERY`] seconds (and when the server stops),
/// not at every join and leave (#1074).
pub(super) fn touch(&mut self, file: &str) {
let t = now();
let due = self.files.get_mut(file).is_some_and(|e| {
e.meta.opened = t;
t >= e.opened_written.saturating_add(TOUCH_EVERY)
});
if due {
self.write_opened(file);
}
}
/// Write `file`'s `opened` time to its `meta.json`.
pub(super) fn write_opened(&mut self, file: &str) {
let path = self.dir(file).join(META);
if let Some(e) = self.files.get_mut(file) {
match write_json(&path, &e.meta) {
Ok(()) => e.opened_written = e.meta.opened,
Err(err) => log::warn!("file {file}: couldn't update {META}: {err}"),
}
}
}
/// Keep `file`'s session loaded for a while after everyone left ([`PARK_FOR`]), at most
/// [`MAX_PARKED`] files of at most [`MAX_PARKED_BYTES`] together (the ones parked longest let
/// go first; a file larger than that isn't kept).
pub(super) fn park(&mut self, file: &str, server: Server, log: super::Log) {
let size = self.bytes(file);
if size > MAX_PARKED_BYTES {
return;
}
let mut parked: Vec<(String, Instant, u64)> =
self.files.iter().filter_map(|(id, e)| e.parked.as_ref().map(|p| (id.clone(), p.at, e.bytes))).collect();
parked.sort_by_key(|(_, at, _)| *at);
let mut held: u64 = parked.iter().map(|(_, _, b)| b).sum();
let mut n = parked.len();
for (id, _, b) in &parked {
if n < MAX_PARKED && held.saturating_add(size) <= MAX_PARKED_BYTES {
break;
}
if let Some(e) = self.files.get_mut(id) {
e.parked = None;
}
n -= 1;
held = held.saturating_sub(*b);
}
if let Some(e) = self.files.get_mut(file) {
e.parked = Some(Parked { server, log, at: Instant::now() });
}
}
/// `file`'s session as it was left, while it is still kept loaded ([`PARK_FOR`]).
pub(super) fn unpark(&mut self, file: &str) -> Option<(Server, super::Log)> {
let p = self.files.get_mut(file)?.parked.take()?;
(p.at.elapsed() <= PARK_FOR).then_some((p.server, p.log))
}
/// What the server says about its hosting (with `file`'s expiry).
pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo {
let h = &self.cfg.hosting;
let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
}
/// Block or unblock an address (an IPv6 address: its /64); the address as kept.
pub fn block(&mut self, ip: &str, on: bool) -> Result<IpAddr, String> {
let ip = ip_key(ip.trim().parse::<IpAddr>().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
}
let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
if changed {
let list: Vec<String> = self.blocked.iter().map(IpAddr::to_string).collect();
write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
}
Ok(ip)
}
/// The blocked addresses.
pub fn blocked(&self) -> Vec<String> {
self.blocked.iter().map(IpAddr::to_string).collect()
}
/// The reports kept, oldest first.
pub fn reports(&self) -> &[ReportInfo] {
&self.reports
}
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
let reason = clean_reason(reason);
if reason.is_empty() {
return Err("say what is wrong with the file".into());
}
let name = self.entry(file)?.meta.name.clone();
if !self.abuse.allow(ip, Action::Report, Instant::now()) {
return Err("too many reports from your address lately: try again in an hour".into());
}
self.reports.push(ReportInfo {
at: now(),
file: file.to_string(),
name,
link: link.unwrap_or_default().to_string(),
ip: ip.to_string(),
reason,
});
if self.reports.len() > MAX_REPORTS {
let extra = self.reports.len() - MAX_REPORTS;
self.reports.drain(..extra);
}
write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
log::info!("event=report file={file} ip={ip}");
Ok(())
}
}
+279
View File
@@ -0,0 +1,279 @@
use super::{
FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs,
now, write_json,
};
impl Store {
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
/// without the key its address's share and everyone's. A log that took it over its cap is
/// compacted first (the state may be smaller than its history).
pub(super) fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> {
if let Some(why) = self.low_disk() {
return Err(why);
}
let max = self.cfg.max_file_bytes;
let over = |s: &Store| s.bytes(file).saturating_add(n) > max;
// A compaction to make room at most once a minute per file (it rewrites the whole file).
let t = now();
let may_compact = self.files.get(file).is_some_and(|e| {
t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty())
});
if over(self) && may_compact {
if let Some(e) = self.files.get_mut(file) {
e.forced = t;
}
self.compact(file, server);
}
if over(self) {
return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20));
}
let h = &self.cfg.hosting;
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
for (times, _, used) in self.per_block(&ip) {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
}
}
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
if used_all.saturating_add(n) > max_all {
return Err("this server is full: that change wasn't kept".into());
}
Ok(())
}
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
/// One everyone left lately is still loaded: it comes back as it was, nothing read (#1074).
pub(super) fn load(&mut self, file: &str) -> Result<Server, String> {
self.touch(file);
if let Some((server, log)) = self.unpark(file) {
self.entry(file)?.log = Some(log);
return Ok(server);
}
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
let mut issues = Vec::new();
let mut fresh_log = true;
let mut kept = 0;
let log_path = dir.join(LOG);
// A log that's there but can't be read (an IO error, or over a lowered cap) may hold
// confirmed edits: the file isn't opened, and the log is left as it is for another try.
let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
r => r,
};
// One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
let read = match read.map(|b| disk::read_log(&b)) {
Ok(Err(why)) => {
let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
Err(format!("{why}; it was moved aside to {}", aside.display()))
}
Ok(Ok(log)) => Ok(log),
Err(why) => Err(why),
};
match read {
Ok(log) if log.generation == generation => {
fresh_log = false;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
fresh_log = true;
break;
}
kept += 1;
}
if let Some(why) = log.damaged {
issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
fresh_log = true;
}
}
// From before a compaction that was cut short, the snapshot has it all; or a generation
// field damaged, which no checksum covers. Replaying tells them apart without trusting
// it: entries the snapshot holds are skipped, so only edits it lacks change anything.
Ok(log) => {
let before = server.seq();
let mut stopped = None;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
stopped = Some(e);
break;
}
}
let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
if recovered > 0 {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
{recovered} changes the snapshot didn't; they were recovered"
));
}
if let Some(e) = stopped {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
making sense after {recovered} newer changes ({e}); the rest was dropped"
));
}
}
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
}
for i in issues {
log::warn!("{i}");
self.note(i);
}
server.journaling(true);
let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
let log = match (fresh_log, file_handle) {
(false, Ok(f)) => {
let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
// Replayed entries make it worth compacting once everyone has left.
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
_ => {
let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
self.rewrite(file, next, &server).map_err(|e| e.why)?
}
};
self.entry(file)?.log = Some(log);
Ok(server)
}
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
/// document (recent batches for reconnecting clients, deletions), which may take it over.
pub(super) fn snapshot_cap(&self) -> u64 {
self.cfg.max_file_bytes.saturating_mul(2)
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
let dir = self.dir(file);
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
let state = server.state().map_err(|e| kept(e.to_string()))?;
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
// and log still hold everything.
if bytes.len() as u64 > self.snapshot_cap() {
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
}
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
// From here the old log no longer goes with the snapshot on disk.
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
if let Ok(e) = self.entry(file) {
e.bytes = (bytes.len() + LOG_HEADER) as u64;
e.meta.seq = server.seq();
let meta = e.meta.clone();
if let Err(err) = write_json(&dir.join(META), &meta) {
log::warn!("file {file}: couldn't update {META}: {err}");
}
}
Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
}
/// Compactions so far.
pub fn compactions(&self) -> u64 {
self.compactions
}
/// Why nothing more may be written when the data directory's disk is nearly full (less free
/// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space
/// can't be told, nothing is refused.
pub(super) fn low_disk(&mut self) -> Option<String> {
let now = Instant::now();
let free = match self.free {
Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free,
_ => {
let free = disk::free_bytes(&self.root);
self.free = Some((now, free));
free
}
};
free.filter(|f| *f < self.cfg.min_free_bytes)
.map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string())
}
/// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept.
pub(super) fn note(&mut self, problem: String) {
if self.problems.len() >= MAX_PROBLEMS {
self.problems.remove(0);
}
self.problems.push(problem);
}
/// Compact an open file: its state as the next snapshot, an empty log.
pub(super) fn compact(&mut self, file: &str, server: &Server) {
self.compactions += 1;
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
let Some(next) = generation.checked_add(1) else {
log::error!("file {file}: compaction failed: the file's generation can't go any higher");
return;
};
match self.rewrite(file, next, server) {
Ok(log) => {
if let Ok(e) = self.entry(file) {
e.log = Some(log);
}
log::debug!("compacted file {file}");
}
// The new snapshot holds everything, but a restart would drop whatever is appended to
// the old log now (its generation no longer matches): the file closes, the hub hears
// it at the next flush, and it opens again from the new snapshot.
Err(RewriteError { why, snapshot_replaced: true }) => {
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
log::error!("file {file}: compaction was cut short: {why}");
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
self.cut_short.push(file.to_string());
}
// The old snapshot and log still hold everything: carry on appending.
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
}
}
/// Write and sync what's pending in every open log; the files whose log couldn't be written.
/// Those are closed here (their pending records dropped: nobody was told of them) and open
/// again from what is on disk.
pub(super) fn flush_all(&mut self) -> Vec<String> {
let mut failed = std::mem::take(&mut self.cut_short);
for (id, e) in &mut self.files {
if let Some(log) = e.log.as_mut() {
match flush_log(id, log) {
Ok(n) => e.bytes += n,
Err(()) => {
e.log = None;
failed.push(id.clone());
}
}
}
}
failed
}
}
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
/// can't be trusted to hold them).
pub(super) fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
if log.pending.is_empty() {
return Ok(0);
}
disk::counted(true);
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
Ok(()) => {
let n = log.pending.len() as u64;
log.bytes += n;
log.pending.clear();
Ok(n)
}
Err(e) => {
log::error!("file {id}: couldn't write its log: {e}");
Err(())
}
}
}
+165
View File
@@ -0,0 +1,165 @@
use super::{
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
write_json,
};
impl Store {
/// A file's team library for `ip`, unless its version is `have`; within what one address and
/// everyone may download (the size is known before anything is read).
pub(super) fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
if have == Some(lib.version) {
return Ok(LibraryReply::Unchanged { version: lib.version });
}
let path = library_file(&dir, lib.version);
let size = fs::metadata(&path).map_or(0, |m| m.len());
let now = Instant::now();
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_spread(now, size as f64) {
return Err(BUSY_DOWNLOADS.into());
}
Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
}
/// Keep `archive` as a file's team library: it must read as a library (with the limits files
/// using it read it with), be the file's library if it has one, and be a newer version.
// shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile);
// move it to a worker if large libraries make that noticeable.
pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result<u64, String> {
let max = self.cfg.max_file_bytes;
if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() {
return Err(NO_PUBLIC_LIBRARY.into());
}
if archive.len() as u64 > max {
return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
}
// Everything it unpacks to counts against the same cap, so a small archive can't expand
// into a large library in memory.
let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) {
Ok(Some(lib)) => lib,
Ok(None) => return Err("that isn't a published library".into()),
Err(e) => return Err(format!("the library can't be read: {e}")),
};
if lib.doc.node_count() > self.cfg.core.max_nodes {
return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes));
}
let m = &lib.manifest;
let dir = self.dir(file);
let entry = self.entry(file)?;
if let Some(old) = &entry.meta.library {
if old.id != m.id {
return Err(format!("this file's library on the server is another one (\"{}\")", old.name));
}
if m.version <= old.version {
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
}
// A jump would let a client use up the numbers and leave no newer version to publish.
if m.version > old.version.saturating_add(1) {
return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
}
} else if m.version > MAX_FIRST_LIBRARY_VERSION {
return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
}
// Each version has a file of its own, written before the description names it: a failed
// description write or a crash in between leaves the previous version served whole, never
// the new bytes under the old number.
let path = dir.join(library_name(m.version));
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
// Never served (the description doesn't name it); the next publish of it replaces it.
if let Err(r) = fs::remove_file(&path) {
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
}
entry.meta.library = before;
return Err(format!("couldn't keep the library: {e}"));
}
// The previous version isn't served any more.
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
}
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
Ok(m.version)
}
/// A library's usage reports in memory (read from disk the first time; a damaged list starts
/// over: it is only statistics). `None` when the file has no library.
fn usage_of(&mut self, file: &str) -> Result<Option<&mut UsageCache>, String> {
if self.entry(file)?.meta.library.is_none() {
return Ok(None);
}
if !self.usage.contains_key(file) {
let path = self.dir(file).join(USAGE);
let list = if path.exists() {
disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
} else {
Vec::new()
};
self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() });
}
Ok(self.usage.get_mut(file))
}
/// The usage reports kept for a file's library (none when it has no library).
pub fn library_usage(&mut self, file: &str) -> Result<Vec<FileUsage>, String> {
Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default())
}
/// Keep how one file uses a file's library (in memory; written when it changed, at most every
/// [`USAGE_WRITE_EVERY`] seconds).
pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
let name = clean_name(&usage.file);
if name.is_empty() {
return Ok(());
}
let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
components.sort();
components.dedup_by_key(|c| c.0);
components.truncate(MAX_REPORTED);
let Some(u) = self.usage_of(file)? else { return Ok(()) };
match u.list.iter_mut().find(|f| f.usage.file == name) {
Some(f) if f.usage.components == components => return Ok(()),
Some(f) => {
f.usage.components = components;
f.updated = now();
}
None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
}
if u.list.len() > MAX_USERS {
u.list.sort_by_key(|f| std::cmp::Reverse(f.updated));
u.list.truncate(MAX_USERS);
}
u.dirty = true;
if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) {
self.write_usage(false);
}
Ok(())
}
/// Write the usage reports that changed (`all`: whenever they were last written).
pub(super) fn write_usage(&mut self, all: bool) {
let t = now();
let root = self.root.join(FILES);
for (file, u) in &mut self.usage {
if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) {
if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) {
log::warn!("file {file}: library usage couldn't be kept: {e}");
}
(u.dirty, u.written) = (false, t);
}
}
}
}
/// The name of the file holding version `version` of a file's team library.
pub fn library_name(version: u64) -> String {
format!("library-{version}.bin")
}
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
/// version published before each version had one.
fn library_file(dir: &Path, version: u64) -> PathBuf {
let path = dir.join(library_name(version));
if path.exists() { path } else { dir.join(LIBRARY) }
}
+171
View File
@@ -0,0 +1,171 @@
use super::{
Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed,
Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json,
};
impl Store {
/// The files kept.
pub fn list(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileInfo> {
self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
}
/// A file's links.
pub fn links(&mut self, file: &str) -> Result<Vec<LinkInfo>, String> {
Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
}
/// A new link to a file.
pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result<NewLink, String> {
let max = self.cfg.max_links;
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
if entry.links.len() >= max {
return Err(format!("a file has at most {max} links: revoke one first"));
}
let (link, token) = new_link(access, &clean_name(label), now())?;
entry.links.push(link.clone());
if let Err(e) = write_json(&path, &entry.links) {
entry.links.pop();
return Err(format!("couldn't keep the link: {e}"));
}
Ok(NewLink { link: link.info(file), token })
}
/// The link of `entry` whose token `token` is (every link is compared, in constant time, so
/// timing doesn't tell which one matched).
pub(super) fn link_of(entry: &Entry, token: &str) -> Option<usize> {
let presented = hash(token);
let mut found = None;
for (i, l) in entry.links.iter().enumerate() {
if same_secret(&presented, &l.hash) {
found = Some(i);
}
}
found
}
/// These join codes of `file` no longer open anything: typing one says so. A code is dropped
/// from the index once none of `file`'s links holds it.
fn forget_codes(&mut self, file: &str, codes: impl IntoIterator<Item = Sealed>) {
for c in codes {
let held = self.files.get(file).is_some_and(|e| e.links.iter().any(|l| l.code.as_ref().is_some_and(|k| k.hash == c.hash)));
if !held && let Some(files) = self.codes.get_mut(&c.hash) {
files.remove(file);
if files.is_empty() {
self.codes.remove(&c.hash);
}
}
self.gone_codes.push_back(c.hash);
}
let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
self.gone_codes.drain(..extra);
}
/// Where the link with the join code `key` is: its file, its place among the file's links
/// and its token. Only the files the index names for the code's hash are looked at; their
/// links are each compared, in constant time.
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
let mut found = None;
for id in self.codes.get(key.hash())? {
let Some(e) = self.files.get(id) else { continue };
for (i, l) in e.links.iter().enumerate() {
if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
found = Some((id.clone(), i, token));
}
}
}
found
}
/// A new join code for the link of `file` whose token is `token` (it takes the place of the
/// link's earlier code): the code, shown this once.
pub fn set_code(&mut self, file: &str, token: &str) -> Result<String, String> {
let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
// 45 random bits: one already in use is all but impossible, and never handed out twice.
let (code, key) = loop {
let code = codes::new_code()?;
let key = codes::Key::new(&code);
if self.find_code(&key).is_none() {
break (code, key);
}
};
let sealed = key.seal(token)?;
let found_by = sealed.hash.clone();
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
let before = link.code.replace(sealed);
let id = link.id.clone();
if let Err(e) = write_json(&path, &entry.links) {
if let Some(l) = entry.links.get_mut(at) {
l.code = before;
}
return Err(format!("couldn't keep the join code: {e}"));
}
self.codes.entry(found_by).or_default().insert(file.to_string());
self.forget_codes(file, before);
log::info!("event=code file={file} link={id}");
Ok(code)
}
/// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
/// the address (see `abuse`), and nothing is looked up for an address that is locked out.
pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
let now = Instant::now();
if let Some(left) = self.abuse.code_locked(ip, now) {
return Err(format!(
"Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
left.as_secs() / 60 + 1
));
}
if !self.abuse.allow(ip, Action::Code, now) {
return Err("Too many codes tried from your address: wait a minute and try again".into());
}
if !self.abuse.codes_open(now) {
return Err(CODES_BUSY.into());
}
// Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
let found = key.as_ref().and_then(|k| self.find_code(k));
self.abuse.code_tried(ip, found.is_some(), now);
match found {
Some((file, _, token)) => {
log::info!("event=code_used file={file} ip={ip}");
Ok((file, token))
}
None => {
let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
Err(if gone { CODE_GONE } else { NO_CODE }.into())
}
}
}
/// Revoke a link.
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
let gone = entry.links.remove(at);
if let Err(e) = write_json(&path, &entry.links) {
entry.links.insert(at, gone);
return Err(format!("couldn't revoke the link: {e}"));
}
log::info!("revoked link {link} of file {file}");
self.forget_codes(file, gone.code);
Ok(())
}
/// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
/// longer served either way).
pub fn remove(&mut self, file: &str) -> Result<(), String> {
// Closed first: an open log would keep the folder from being deleted on Windows.
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
self.forget_codes(file, entry.links.iter().filter_map(|l| l.code.clone()));
drop(entry);
let dir = self.dir(file);
fs::remove_dir_all(&dir)
.map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
log::info!("removed file {file}");
Ok(())
}
}
+218
View File
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
#[test]
fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
let dir = TempDir::new("read-only-reconnects");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, original) = {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Read only");
let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
(id, bytes)
};
let mut last_client = 0;
for _ in 0..6 {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let mut server = store.open(&id).unwrap();
let client = join(&mut server, Access::View);
assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
last_client = client.id();
store.changed(&id, &mut server);
// Idle must sync even if no periodic flush ran before the connection closed.
assert!(store.idle(&id, &mut server));
assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
assert!(!log.entries.is_empty());
assert_eq!(server.seq(), 0);
}
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
@@ -229,6 +257,57 @@ fn one_server_per_data_directory() {
assert_eq!(again.key(), key, "the key is kept");
}
#[test]
fn operator_key_authentication() {
let key = "operator-key-0123456789abcdef";
for open in [false, true] {
let dir = TempDir::new("operator-key");
fs::write(dir.path().join(crate::store::KEY), format!("{key}\n")).unwrap();
let mut c = cfg(dir.path());
c.hosting.open = open;
let mut store = Store::open(dir.path(), c).unwrap();
let refusal = match open {
true => "wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)",
false => "wrong server key",
};
assert_eq!(store.manage(key), Ok(()));
let same_length: String = key.chars().rev().collect();
for wrong in [same_length.as_str(), "", &key[..key.len() - 1], &format!("{key}x"), &format!("{key}\n")] {
assert_eq!(store.manage(wrong), Err(refusal.to_string()), "{wrong:?} is refused");
}
}
}
#[test]
fn operator_key_rotation_survives_restart() {
let dir = TempDir::new("operator-key-rotation");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let old = store.key().to_string();
assert_eq!(store.manage(&old), Ok(()));
let new = store.rotate_key().unwrap();
assert_ne!(new, old);
assert_eq!(store.key(), new);
assert!(store.manage(&old).is_err(), "the old key stops working");
assert_eq!(store.manage(&new), Ok(()));
drop(store);
let mut again = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert_eq!(again.key(), new);
assert!(again.manage(&old).is_err(), "the old key stays refused after a restart");
assert_eq!(again.manage(&new), Ok(()));
assert_eq!(fs::read_to_string(dir.path().join(crate::store::KEY)).unwrap(), format!("{new}\n"), "the key file keeps the raw key");
}
#[cfg(unix)]
#[test]
fn an_existing_data_directory_is_made_private() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("perms");
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
assert!(crate::store::lock(dir.path()).unwrap().is_some());
let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn temporary_directories_are_removed() {
let path = {
@@ -241,6 +320,19 @@ fn temporary_directories_are_removed() {
assert!(!path.exists());
}
#[test]
fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
let body = figcraft_collab::message::encode(&state).unwrap();
let mut legacy = b"FCSN\x01".to_vec();
legacy.extend_from_slice(&37u64.to_le_bytes());
legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
legacy.extend_from_slice(&body);
let encoded = disk::encode_snapshot(37, &state).unwrap();
assert_eq!(encoded, legacy);
assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
}
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
@@ -313,3 +405,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
#[test]
fn a_log_that_cant_be_read_is_never_overwritten() {
let dir = TempDir::new("unreadlog");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let log_path = dir.path().join(FILES);
let last = "Confirmed page name number 39 with some padding";
let id = {
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);
for i in 0..40 {
rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding"));
store.changed(&id, &mut server);
}
assert!(store.flush().is_empty());
id
};
let log_path = log_path.join(&id).join(LOG);
let before = fs::read(&log_path).unwrap();
// The cap lowered just under the log: it can't be read now, but it must still be there after.
let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() };
{
let mut store = Store::open(dir.path(), lowered).unwrap();
let opened = store.open(&id);
assert!(opened.is_err(), "the file isn't opened without its log");
assert!(!store.problems().is_empty(), "the problem is reported");
assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was");
}
let mut store = Store::open(dir.path(), c).unwrap();
let server = store.open(&id).unwrap();
assert_eq!(page_name(&server), last, "every confirmed edit comes back");
}
#[test]
fn a_log_that_isnt_one_is_moved_aside() {
let dir = TempDir::new("badlog");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let folder = dir.path().join(FILES).join(&id);
let junk = b"not a log at all, but maybe worth keeping".to_vec();
fs::write(folder.join(LOG), &junk).unwrap();
drop(store);
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
store.open(&id).unwrap();
assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems());
let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk));
assert!(kept, "the old log's bytes are kept beside the file");
}
const BIDI: [char; 12] =
['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}'];
#[test]
fn names_and_report_reasons_lose_bidi_controls() {
let dir = TempDir::new("bidi");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
for (n, m) in BIDI.into_iter().enumerate() {
let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap();
let (id, _, _) = publish(&mut store, &format!("a{m}b"));
assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32);
store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap();
assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32);
store.remove(&id).unwrap();
}
// A reason that is only control characters says nothing.
let (id, _, _) = publish(&mut store, "blank");
for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] {
assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}");
}
}
#[test]
fn a_maxed_out_generation_does_not_wrap() {
let dir = TempDir::new("maxgen");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Max");
let path = dir.path().join(FILES).join(&id);
let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
// Loading: no log goes with the snapshot, so the next generation would overflow.
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap();
fs::remove_file(path.join(LOG)).unwrap();
assert!(store.open(&id).is_err());
// Compacting: the open log is at the largest generation.
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap();
let _ = fs::remove_file(path.join(LOG));
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
for i in 0..4 {
rename(&mut server, &mut a, &format!("P{i}"));
store.changed(&id, &mut server);
store.flush();
}
let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(snap, u64::MAX);
}
#[test]
fn every_leftover_temporary_file_is_removed_at_startup() {
let dir = TempDir::new("leftovers");
let id = {
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let id = publish(&mut store, "A").0;
store.flush();
id
};
let folder = dir.path().join(FILES).join(&id);
let leftovers = [
dir.path().join("blocked.json.tmp"),
dir.path().join("reports.json.tmp"),
dir.path().join("server.key.tmp"),
folder.join("usage.json.tmp"),
folder.join("library-1.bin.tmp"),
];
for path in &leftovers {
fs::write(path, b"half").unwrap();
}
let _store = Store::open(dir.path(), cfg(dir.path())).unwrap();
for path in &leftovers {
assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display());
}
}
+14
View File
@@ -170,3 +170,17 @@ impl Raw {
}
}
}
/// A one-page file with one rectangle, named `name`, as a snapshot to publish.
pub fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = figcraft_doc::Document::new(name);
let page = d.first_page().unwrap();
d.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
figcraft_collab::Replica::new(d).snapshot().unwrap()
}
/// An address from text.
pub fn ip(s: &str) -> std::net::IpAddr {
s.parse().unwrap()
}
@@ -2,7 +2,6 @@
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
//! still restores silently.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::{Client, Outgoing, Replica, Server};
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod header_recovery;
mod problem_retention;
+6
View File
@@ -0,0 +1,6 @@
# The browser build (trunk runs cargo from this folder; packaging/web/package.sh). With simd128 the
# glyph rasterizer's SIMD code compiles for wasm SIMD instead of its much larger scalar fallback:
# about 1.6 MB less wasm. Every current browser has wasm SIMD (Safari since 16.4). The plugin
# workspace is built from the repository root, so its modules stay without SIMD for the plugin runtime.
[target.wasm32-unknown-unknown]
rustflags = ["-Ctarget-feature=+simd128"]
+5 -1
View File
@@ -15,10 +15,14 @@ path = "src/main.rs"
[dependencies]
figcraft-engine = { workspace = true }
figcraft-format = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
base64 = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
@@ -26,4 +30,4 @@ 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", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
+8 -4
View File
@@ -2,17 +2,21 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>FigCraft</title>
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue --enable-simd" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
/* Shown once the page stopped on an internal error (web.rs: show_stopped). */
#figcraft_stopped { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box;
padding: 16px; text-align: center; color: #1e1e1e; background: rgba(245, 245, 245, 0.92); font: 14px/1.5 system-ui, sans-serif; }
@media (prefers-color-scheme: dark) {
html, body { background: #2c2c2c; }
#figcraft_loading { color: #b8b8b8; }
#figcraft_stopped { color: #f0f0f0; background: rgba(44, 44, 44, 0.92); }
}
</style>
</head>
+172
View File
@@ -0,0 +1,172 @@
//! `window.figcraft`: the control channel for the page, as promises.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving FigCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
//! await figcraft.request("document.inspect") // control method → its result
//! await figcraft.commands() // command list
//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64)
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
//! figcraft.captureDownloads(true) // saves stay in the page…
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, TryRecvError};
use base64::Engine as _;
use figcraft_ui_egui::{ControlReply, ControlRequest};
use serde_json::{Value, json};
use wasm_bindgen::prelude::*;
use crate::saved_files::SavedFiles;
type Waiting = (Receiver<ControlReply>, js_sys::Function, js_sys::Function);
thread_local! {
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
pub fn connect(ctx: egui::Context) {
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
/// The page's requests since the last frame, for the app's control queue.
pub fn take_requests() -> Vec<ControlRequest> {
QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(name: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(name, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(ControlReply { result: Ok(v), .. }) => {
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
false
}
Ok(ControlReply { result: Err(e), .. }) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e));
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
}
});
});
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (reply, rx) = std::sync::mpsc::channel();
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() };
QUEUE.with(|q| q.borrow_mut().push(req));
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
});
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
p
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.figcraft`.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}}))
})
.into_js_value(),
)?;
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
set_fn(
&obj,
"files",
Closure::<dyn Fn() -> JsValue>::new(|| {
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
})
.into_js_value(),
)?;
set_fn(
&obj,
"readFile",
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
SAVED.with(|s| {
s.borrow()
.get(&name)
.map(|b| js_sys::Uint8Array::from(b).into())
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
})
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?;
Ok(())
}
+39
View File
@@ -0,0 +1,39 @@
//! What the page keeps across reloads, in the browser's local storage: your name (Preferences ▸
//! Your name), so a reload or a later visit joins live sessions and comments as you.
use figcraft_engine::Session;
use serde_json::json;
/// Put back the name the page kept, checked as one typed in Preferences: the page's
/// storage is anyone's to change.
pub fn restore_name(session: &mut Session, kept: Option<String>) {
if let Some(name) = kept
&& let Err(e) = session.execute("pref.userName", &json!({"name": name}))
{
log::warn!("the kept name wasn't used: {e}");
}
}
#[cfg(test)]
mod tests {
use figcraft_engine::Session;
use serde_json::json;
use super::restore_name;
/// What one page kept, the next one restores; junk in the storage is cleaned like typing.
#[test]
fn the_name_survives_a_reload() {
let mut before = Session::new();
before.execute("pref.userName", &json!({"name": " Alice "})).unwrap();
let kept = before.prefs.user_name.clone();
let mut after = Session::new();
restore_name(&mut after, Some(kept));
assert_eq!(after.prefs.user_name, "Alice");
let mut fresh = Session::new();
restore_name(&mut fresh, None);
assert_eq!(fresh.prefs.user_name, Session::new().prefs.user_name, "nothing kept: the default");
restore_name(&mut fresh, Some(format!("Bob\u{7}{}", "x".repeat(500))));
assert!(fresh.prefs.user_name.starts_with("Bobx") && fresh.prefs.user_name.chars().count() == 64);
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Fonts installed on this computer, through the browser's Local Font Access API
//! (`window.queryLocalFonts`, Chromium): asked for when the person clicks (the browser asks them
//! for permission), then known by name in the font list. A font's bytes are read only when text
//! uses it: `figcraft_text` asks for them ([`load_wanted`], each frame) and takes them when they
//! come; auto-sizing text then fits the real font.
use std::cell::{Cell, RefCell};
use figcraft_text::LocalFont;
use js_sys::{Array, Function, Promise, Reflect, Uint8Array};
use wasm_bindgen::{JsCast as _, JsValue};
use wasm_bindgen_futures::JsFuture;
/// Most fonts taken from the browser's list (a computer with a font manager can have tens of
/// thousands; each is only a name here until it is used).
const MAX_FONTS: usize = 20_000;
thread_local! {
/// The browser's `FontData` of each font offered, by its number.
static FONTS: RefCell<Vec<JsValue>> = const { RefCell::new(Vec::new()) };
/// Asked for already (the browser asks the person once; their answer stands for the page).
static ASKED: Cell<bool> = const { Cell::new(false) };
/// A font's bytes came since the last frame: auto-sizing text fits them.
static LOADED: Cell<bool> = const { Cell::new(false) };
}
/// The browser can list the fonts installed on this computer, and the page hasn't asked yet.
pub fn can_ask() -> bool {
!ASKED.with(Cell::get) && web_sys::window().is_some_and(|w| Reflect::has(&w, &"queryLocalFonts".into()).unwrap_or(false))
}
/// Ask the browser for the installed fonts (it asks the person); `ctx` repaints when they're in.
pub fn ask(ctx: egui::Context) {
ASKED.with(|a| a.set(true));
wasm_bindgen_futures::spawn_local(async move {
match query().await {
Ok(n) => log::info!("fonts on this computer: {n} new families"),
Err(e) => log::warn!("the browser didn't list the fonts on this computer: {e:?}"),
}
ctx.request_repaint();
});
}
/// A `FontData`'s text field.
fn text(font: &JsValue, key: &str) -> String {
Reflect::get(font, &key.into()).ok().and_then(|v| v.as_string()).unwrap_or_default()
}
/// Call `obj.name()`, a method returning a promise, and wait for it.
async fn call(obj: &JsValue, name: &str) -> Result<JsValue, JsValue> {
let f: Function = Reflect::get(obj, &name.into())?.dyn_into()?;
JsFuture::from(f.call0(obj)?.dyn_into::<Promise>()?).await
}
/// The browser's font list, offered to the font database by name: the number of new families.
async fn query() -> Result<usize, JsValue> {
let window: JsValue = web_sys::window().ok_or("no window")?.into();
let list: Array = call(&window, "queryLocalFonts").await?.dyn_into()?;
let fonts = FONTS.with(|all| {
let mut all = all.borrow_mut();
list.iter()
.take(MAX_FONTS.saturating_sub(all.len()))
.map(|font| {
let f = LocalFont {
id: all.len(),
family: text(&font, "family"),
style: text(&font, "style"),
postscript: text(&font, "postscriptName"),
};
all.push(font);
f
})
.collect()
});
Ok(figcraft_text::add_local_fonts(fonts).len())
}
/// Read the fonts text asked for since the last frame; `ctx` repaints as each comes.
pub fn load_wanted(ctx: &egui::Context) {
for id in figcraft_text::take_wanted_local_fonts() {
let Some(font) = FONTS.with(|f| f.borrow().get(id).cloned()) else { continue };
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
// A font that can't be read is dropped (its text keeps the fallback).
let bytes = read(&font).await.unwrap_or_else(|e| {
log::warn!("couldn't read a font from this computer: {e:?}");
Vec::new()
});
if figcraft_text::load_local_font(id, bytes).is_some() {
LOADED.with(|l| l.set(true));
}
ctx.request_repaint();
});
}
}
/// A `FontData`'s file, at most [`figcraft_text::fontdb::MAX_FONT_FILE`] bytes.
async fn read(font: &JsValue) -> Result<Vec<u8>, JsValue> {
let blob: web_sys::Blob = call(font, "blob").await?.dyn_into()?;
if blob.size() > figcraft_text::fontdb::MAX_FONT_FILE as f64 {
return Err("the font file is too large".into());
}
let buf = JsFuture::from(blob.array_buffer()).await?;
Ok(Uint8Array::new(&buf).to_vec())
}
/// A font's bytes came since the last call.
pub fn take_loaded() -> bool {
LOADED.with(|l| l.replace(false))
}
+25 -5
View File
@@ -8,22 +8,42 @@
//! - no TCP control server (browsers can't listen on sockets);
//! - no native file dialogs yet: the session starts with one new document;
//! - links open in a new browser tab;
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
//! host one. A page served over `https://` can only reach sessions on the same computer
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
//! join from other machines.
//! - of the preferences, the page keeps only your name across reloads (`kept.rs`);
//! - an internal error (a panic) can't be caught, so it stops the page: the files with unsaved
//! changes are kept in the page's storage and open again on the next reload (`recovery.rs`);
//! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this
//! computer…", Chromium's Local Font Access) the computer's, each read when text uses it
//! (`local_fonts.rs`);
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) and files on
//! a `figcraft-server`, and publishes files to a server (Share ▸ Online, Share ▸ Publish to
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
//! bar and the browser history keep it, so the URL is as private as the link itself.
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod kept;
#[cfg(target_arch = "wasm32")]
mod local_fonts;
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod recovery;
#[cfg(test)]
mod recovery_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+8 -1
View File
@@ -1,4 +1,4 @@
//! The page's URL: the invite link its fragment names.
//! The page's URL: the invite link its fragment names, and the flags its query sets.
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
@@ -13,3 +13,10 @@ pub fn join_link(fragment: &str) -> Option<&str> {
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
}
/// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`,
/// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only
/// when it is asked for.
pub fn control(search: &str) -> bool {
search.strip_prefix('?').unwrap_or(search).split('&').any(|entry| entry.split('=').next() == Some("control"))
}
+13 -2
View File
@@ -1,6 +1,6 @@
//! `#join=` in the page's URL.
//! `#join=` and `?control` in the page's URL.
use crate::query::join_link;
use crate::query::{control, join_link};
#[test]
fn join_takes_the_rest_of_the_fragment() {
@@ -27,3 +27,14 @@ fn an_overlong_link_is_ignored() {
assert_eq!(join_link(&long), None);
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
}
#[test]
fn control_is_opt_in() {
assert!(control("?control"));
assert!(control("?webgl&control=1"), "after other entries, with a value");
assert!(control("control"), "no leading ?");
assert!(!control(""));
assert!(!control("?"));
assert!(!control("?webgl"));
assert!(!control("?nocontrol&controls"), "only the whole entry name");
}
+85
View File
@@ -0,0 +1,85 @@
//! Recovery copies in the browser. The browser build can't catch a panic (wasm32 always aborts),
//! so a command or frame that panics ends the page instead of failing as on the desktop. The page
//! follows its files with unsaved changes after every frame ([`Unsaved::follow`]: cheap shared
//! copies, taken only when a file changed); the panic hook writes them to the page's local storage
//! ([`store`]) before the page stops, and the next page opens them again as unsaved files
//! ([`restore`], `file.recover`), as the desktop app does with its recovery copies.
use std::sync::Arc;
use base64::Engine as _;
use figcraft_engine::branches::{needs_copy, recovery_copy, stamp};
use figcraft_engine::doc::Document;
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
/// A file with unsaved changes as of the last frame.
struct Copy {
uid: u64,
/// [`stamp`]: the revisions this copy is of.
stamp: Vec<u64>,
doc: Arc<Document>,
versions: Vec<Version>,
branches: Vec<Branch>,
}
impl Copy {
fn of(s: &Session, d: &DocState) -> Self {
let (doc, versions, branches) = recovery_copy(s, d);
Self { uid: d.uid, stamp: stamp(s, d), doc, versions, branches }
}
}
/// The page's files with unsaved changes, as of the last frame.
#[derive(Default)]
pub struct Unsaved(Vec<Copy>);
impl Unsaved {
/// Catch up with `s`: a file that changed is taken again, the others are kept as they are.
pub fn follow(&mut self, s: &Session) {
let mut before = std::mem::take(&mut self.0);
for d in s.docs.iter().filter(|d| needs_copy(s, d)) {
let now = stamp(s, d);
let copy = match before.iter().position(|c| c.uid == d.uid && c.stamp == now) {
Some(i) => before.swap_remove(i),
None => Copy::of(s, d),
};
self.0.push(copy);
}
}
/// How many files have unsaved changes.
pub fn count(&self) -> usize {
self.0.len()
}
/// Each file as a saved `.figcraft` file in base64 (one that can't be written is left out).
pub fn files(&self) -> Vec<String> {
let b64 = base64::engine::general_purpose::STANDARD;
self.0
.iter()
.filter_map(|c| figcraft_format::save_bytes_with_history(&c.doc, None, &c.versions, &c.branches).ok().map(|b| b64.encode(b)))
.collect()
}
}
/// Keep as many of `files` as the storage takes, in order: `put` stores the list's JSON and says
/// whether it fitted (a refused write leaves what was stored before). Returns how many were kept.
pub fn store(files: Vec<String>, mut put: impl FnMut(&str) -> bool) -> usize {
let mut kept = Vec::new();
for f in files {
kept.push(f);
if !put(&json!(kept).to_string()) {
kept.pop();
}
}
kept.len()
}
/// Open the copies the last page kept (`kept`: what [`store`] stored, if anything) as files with
/// unsaved changes. Returns how many came back; junk in the storage is skipped.
pub fn restore(s: &mut Session, kept: Option<String>) -> usize {
let files: Vec<String> = kept.and_then(|k| serde_json::from_str(&k).ok()).unwrap_or_default();
files.iter().filter(|f| s.execute("file.recover", &json!({"data": f})).is_ok()).count()
}
+115
View File
@@ -0,0 +1,115 @@
//! Recovery copies in the browser: what a page kept when it stopped opens again, unsaved, on the
//! next page.
use figcraft_engine::Session;
use figcraft_ui_egui::browser_stopped;
use serde_json::json;
use crate::recovery::{Unsaved, restore, store};
fn edited(name: &str) -> Session {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("file.rename", &json!({"name": name})).unwrap();
s
}
/// The page's copies, stored as the panic hook stores them.
fn stored(u: &Unsaved) -> Option<String> {
let mut out = None;
let kept = store(u.files(), |v| {
out = Some(v.to_string());
true
});
assert_eq!(kept, u.count());
out
}
#[test]
fn a_stopped_pages_unsaved_files_open_again_unsaved() {
let s = edited("Poster");
let mut u = Unsaved::default();
u.follow(&s);
assert_eq!(u.count(), 1);
let mut next = Session::new();
let before = next.docs.len();
assert_eq!(restore(&mut next, stored(&u)), 1);
assert_eq!(next.docs.len(), before + 1);
let d = next.ds().unwrap();
assert_eq!(d.doc.name, "Poster");
assert!(d.is_dirty(), "a recovered file has unsaved changes");
assert!(d.path.is_none(), "saving it asks where");
}
#[test]
fn the_latest_edit_is_kept_and_a_saved_file_is_not() {
let mut s = edited("First");
let mut u = Unsaved::default();
u.follow(&s);
u.follow(&s);
assert_eq!(u.count(), 1, "following again without a change keeps the one copy");
s.execute("file.rename", &json!({"name": "Later"})).unwrap();
u.follow(&s);
let mut next = Session::new();
assert_eq!(restore(&mut next, stored(&u)), 1);
assert_eq!(next.ds().unwrap().doc.name, "Later");
s.execute("file.markSaved", &json!({})).unwrap();
u.follow(&s);
assert_eq!(u.count(), 0);
assert!(u.files().is_empty());
}
#[test]
fn what_the_storage_has_no_room_for_is_left_out() {
let files = vec!["a".repeat(10), "b".repeat(100), "c".repeat(10)];
let mut kept = String::new();
let n = store(files, |v| {
let fits = v.len() < 60;
if fits {
kept = v.to_string();
}
fits
});
assert_eq!(n, 2);
assert_eq!(kept, json!(["a".repeat(10), "c".repeat(10)]).to_string());
assert_eq!(store(vec!["x".into()], |_| false), 0);
}
#[test]
fn junk_in_the_storage_opens_nothing() {
let mut s = Session::new();
let before = s.docs.len();
for junk in [None, Some("not json"), Some("{}"), Some(r#"["!!"]"#), Some(r#"["aGVsbG8="]"#), Some("[1, null]")] {
assert_eq!(restore(&mut s, junk.map(String::from)), 0, "{junk:?}");
}
assert_eq!(s.docs.len(), before);
}
#[test]
fn the_stopped_page_says_what_was_kept() {
let says = |unsaved, kept, what: &str| {
let text = browser_stopped(unsaved, kept);
assert!(text.starts_with("FigCraft stopped because of an internal error. ") && text.contains(what), "{text}");
};
says(0, 0, "start again");
says(2, 2, "get your unsaved files back");
says(2, 0, "too large for the browser to keep");
says(3, 1, "the browser had room for");
}
#[test]
fn a_copy_taken_while_a_version_is_previewed_is_of_the_file() {
let mut s = edited("Poster");
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("file.rename", &json!({"name": "Later"})).unwrap();
// Version v1 (named Poster) shows in place of the file.
s.execute("version.preview", &json!({"index": 0})).unwrap();
assert_eq!(s.doc().unwrap().name, "Poster", "the preview shows");
let mut u = Unsaved::default();
u.follow(&s);
let mut next = Session::new();
assert_eq!(restore(&mut next, stored(&u)), 1);
let d = next.ds().unwrap();
assert_eq!(d.doc.name, "Later", "the file comes back, not the version shown");
assert_eq!(d.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
}
+70
View File
@@ -0,0 +1,70 @@
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
/// Saved files kept (the oldest go first).
pub const MAX_SAVED: usize = 16;
/// The last component of a `/` or `\` path.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
}
/// Files by name, newest last: saving a name again replaces it.
#[derive(Default)]
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
impl SavedFiles {
/// Keep `bytes` under the last component of `path`.
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
let name = file_name(path).to_string();
self.0.retain(|(n, _)| *n != name);
self.0.push((name, bytes.to_vec()));
let extra = self.0.len().saturating_sub(MAX_SAVED);
self.0.drain(..extra);
}
/// A kept file by its name, or by a path ending in it.
pub fn get(&self, name: &str) -> Option<&[u8]> {
let wanted = file_name(name);
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
}
/// `(name, size)` of each kept file, oldest first.
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_by_name_replaces_and_reads_back_by_path() {
let mut s = SavedFiles::default();
s.keep("exports/poster.png", b"one");
s.keep(r"C:\out\poster.png", b"two");
s.keep("notes.txt", b"three");
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("missing.png"), None);
}
#[test]
fn drops_the_oldest_past_the_cap() {
let mut s = SavedFiles::default();
for i in 0..MAX_SAVED + 3 {
s.keep(&format!("f{i}.png"), &[0]);
}
assert_eq!(s.list().count(), MAX_SAVED);
assert_eq!(s.get("f0.png"), None);
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
}
#[test]
fn a_trailing_separator_keeps_the_whole_name() {
let mut s = SavedFiles::default();
s.keep("odd/", b"x");
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
}
}
+210 -8
View File
@@ -1,14 +1,28 @@
//! The browser shell: web `Services` and the eframe web runner.
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
const LOADING_ID: &str = "figcraft_loading";
/// What the page shows once it stopped on an internal error (styled in `index.html`).
const STOPPED_ID: &str = "figcraft_stopped";
/// The local storage key your name is kept under ([`crate::kept`]).
const NAME_KEY: &str = "figcraft.userName";
/// The local storage key the recovery copies are kept under ([`crate::recovery`]): a JSON list of
/// base64 `.figcraft` files.
const RECOVERY_KEY: &str = "figcraft.recovery";
thread_local! {
/// The files with unsaved changes as of the last frame, for the panic hook ([`crate::recovery`]).
static UNSAVED: std::cell::RefCell<crate::recovery::Unsaved> = std::cell::RefCell::default();
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
// Before the runner's own hook, which logs the panic and then calls this one.
keep_unsaved_on_panic();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@@ -36,17 +50,27 @@ pub fn start() {
}
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let mut session = Session::new();
// Live sessions: the browser joins sessions a desktop app hosts; whatever arrives
// on the socket wakes the page for a frame.
crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten()));
let recovered = restore_unsaved(&mut session);
// Live sessions: the browser joins sessions and publishes files to servers;
// whatever arrives 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 mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
fig.note_recovered(recovered);
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
figcraft_ui_egui::live::open_invite(&mut fig, &link);
}
// `?control`: `window.figcraft`, the page's control channel (crate::api), opt-in.
if crate::query::control(&query()) {
crate::api::connect(cc.egui_ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
}),
)
@@ -60,6 +84,46 @@ pub fn start() {
});
}
/// The browser can't catch a panic (wasm32 aborts), so the page ends with it: keep the files with
/// unsaved changes in the page's storage for the next page, and say so on the page.
fn keep_unsaved_on_panic() {
static ONCE: std::sync::Once = std::sync::Once::new();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
previous(info);
// Callbacks that run after the page stopped can panic again: the files are kept once.
ONCE.call_once(|| {
let (unsaved, files) = UNSAVED.with(|u| u.try_borrow().map(|u| (u.count(), u.files())).unwrap_or_default());
let kept = local_storage().map_or(0, |s| crate::recovery::store(files, |v| s.set_item(RECOVERY_KEY, v).is_ok()));
show_stopped(&figcraft_ui_egui::browser_stopped(unsaved, kept));
});
}));
}
/// Open the files the last page kept when it stopped; how many came back.
fn restore_unsaved(session: &mut Session) -> usize {
let Some(storage) = local_storage() else { return 0 };
let kept = storage.get_item(RECOVERY_KEY).ok().flatten();
// Gone before they open: a copy that stops the page again must not come back on every reload
// (once a frame ran, the files it opened are followed as any unsaved file).
let _ = storage.remove_item(RECOVERY_KEY);
crate::recovery::restore(session, kept)
}
/// Say on the page that it stopped (the canvas stays as it was).
fn show_stopped(text: &str) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let (Ok(el), Some(body)) = (document.create_element("div"), document.body()) else { return };
el.set_id(STOPPED_ID);
el.set_text_content(Some(text));
let _ = body.append_child(&el);
}
/// The page's local storage (`None` where the browser keeps none, e.g. storage turned off).
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -69,15 +133,43 @@ struct WebShell {
}
impl eframe::App for WebShell {
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// Pointer and key events sent over the control channel (`ui.input`), for the frame about
// to draw: the batch is applied by this frame and acknowledged once it is presented, as on
// the desktop (`apps/figcraft/src/main.rs`). Without this hook the batch sat in its queue
// and its promise never settled.
let events = figcraft_ui_egui::windows::take_input(&mut self.fig, raw_input.viewport_id);
raw_input.events.extend(events);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.control_queue.extend(crate::api::take_requests());
self.fig.ui(ui);
crate::api::poll_replies();
// The fonts on this computer that text asked for this frame are read; once one is in,
// auto-sizing text measured in a fallback fits it.
crate::local_fonts::load_wanted(ui.ctx());
if crate::local_fonts::take_loaded() {
self.fig.run("text.refit", serde_json::json!({}));
}
}
}
/// 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).
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, 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>,
@@ -85,11 +177,24 @@ struct WebServices {
}
impl Services for WebServices {
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
Some(Box::pin(async move {
pick_file(&accept).await.unwrap_or_else(|e| {
log::error!("couldn't open a file: {e:?}");
None
})
}))
}
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 {
if crate::api::capture(name, bytes) {
return true;
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
@@ -106,13 +211,19 @@ impl Services for WebServices {
}
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") {
if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") {
log::error!("couldn't open {url}: {e:?}");
}
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches)
}
/// Only your name is kept ([`crate::kept`]): the rest starts afresh with each page.
fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) {
if let Some(Err(e)) = local_storage().map(|s| s.set_item(NAME_KEY, &prefs.user_name)) {
log::warn!("couldn't keep your name: {e:?}");
}
}
/// The browser's language (`navigator.language`).
fn system_language(&self) -> Option<String> {
web_sys::window().and_then(|w| w.navigator().language())
@@ -134,6 +245,97 @@ impl Services for WebServices {
}
held
}
/// Local Font Access (`window.queryLocalFonts`, Chromium), not asked yet.
fn can_ask_for_local_fonts(&self) -> bool {
crate::local_fonts::can_ask()
}
fn ask_for_local_fonts(&mut self) {
crate::local_fonts::ask(self.ctx.clone());
}
}
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
/// can't be read.
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, 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 input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
// Some browsers (older Safari) open the picker only for an input in the page.
body.append_child(&input)?;
let (closed, picker) = PickerClosed::new(&window, input.clone());
input.click();
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
let file = input.files().and_then(|files| files.get(0));
drop(picker);
result?;
let Some(file) = file else { return Ok(None) };
let name = file.name();
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
return Ok(Some(Err(why)));
}
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
Err(e) => {
log::error!("couldn't read {name}: {e:?}");
Err(figcraft_ui_egui::unreadable(&name))
}
}))
}
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
struct PickerClosed {
window: web_sys::Window,
input: web_sys::HtmlInputElement,
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
}
impl PickerClosed {
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
let slot = resolve.clone();
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if let Some(res) = resolve.borrow_mut().take() {
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
}
});
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if t.get().is_none() {
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
}
});
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
}
}
impl Drop for PickerClosed {
fn drop(&mut self) {
if let Some(t) = self.timer.take() {
self.window.clear_timeout_with_handle(t);
}
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
self.input.remove();
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
+23 -4
View File
@@ -16,8 +16,14 @@ figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-format = { workspace = true }
figcraft-text = { workspace = true }
# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the
# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
craft-window = { workspace = true }
# The suite's settings folder: `FIGCRAFT_CONFIG_DIR`, a portable marker, then the platform folder.
craft-settings = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
@@ -29,14 +35,28 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
# accesskit already; this only makes it direct.
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
# Paste reads the clipboard's public.png as is, keeping the screen density a screenshot records.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] }
# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
# same guarded path as the close button); winit's default Quit ends the process through
# `terminate:`, losing unsaved changes without a word (issue #857).
craft-menu = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
@@ -45,7 +65,6 @@ wat = { workspace = true }
winresource = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
# Use vector window controls without a title-font renderer or embedded Cantarell.
# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor
# probe that used to need raw-window-handle and wayland-client here now lives in craft-window.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
raw-window-handle = "0.6"
wayland-client = "0.31"
+1
View File
@@ -1,6 +1,7 @@
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
//! (no-op elsewhere).
fn main() {
println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)");
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
+15 -5
View File
@@ -2,12 +2,14 @@
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
//! the app running unconfined. After a bare `--`, every argument is a file.
/// What the app was started with.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Args {
/// `--control <port>`: the JSON-lines control server.
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
pub control: Option<u16>,
pub files: Vec<String>,
/// The last invite link given.
@@ -17,6 +19,8 @@ pub struct Args {
/// which is refused at startup.
pub read_roots: Vec<String>,
pub write_roots: Vec<String>,
/// `--flags` the app doesn't know, as given.
pub unknown: Vec<String>,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args {
let mut out = Args::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--" {
out.files.extend(it.by_ref().cloned());
break;
}
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
let roots = match flag {
"--automation-read-root" => Some(&mut out.read_roots),
@@ -63,13 +71,15 @@ pub fn parse(args: &[String]) -> Args {
};
if let Some(roots) = roots {
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
} else if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if flag == "--control" {
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
} else if is_invite(a) {
out.join = Some(a.clone());
} else if let Some(path) = file_url_path(a) {
out.files.push(path);
} else if !a.starts_with("--") {
} else if a.starts_with("--") {
out.unknown.push(a.clone());
} else {
out.files.push(a.clone());
}
}
+23 -2
View File
@@ -3,19 +3,27 @@
use crate::args::{Args, file_url_path, is_invite, parse};
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
a.iter().map(ToString::to_string).collect()
}
#[test]
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
}
#[test]
fn the_control_port_accepts_the_equals_form() {
let a = parse(&args(&["--control=7980", "a.figcraft"]));
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
// A bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
}
#[test]
fn automation_roots_repeat_and_are_never_files() {
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
@@ -26,6 +34,19 @@ fn automation_roots_repeat_and_are_never_files() {
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
}
#[test]
fn unknown_flags_are_refused_never_ignored() {
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
// unconfined. Both forms, with and without `=`.
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
assert!(a.write_roots.is_empty());
// Single-dash arguments are left as they were (files); after `--` everything is a file.
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
}
#[test]
fn invite_links_join_instead_of_opening() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
+101 -13
View File
@@ -6,7 +6,8 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_ui_egui::ControlRequest;
use figcraft_engine::events::{self, EventLog};
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
@@ -16,6 +17,38 @@ 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);
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Why a request's transport wait ended without a control reply.
#[derive(Debug, PartialEq, Eq)]
enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
/// A commit that got the gate first has already queued its reply: consume that reply instead of
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
match rx.recv_timeout(timeout) {
Ok(reply) => Ok(reply),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
}
}
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
/// against a gate whose commit already happened.
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
@@ -26,8 +59,20 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.extend(queue.drain(..count));
}
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
/// Bind `port`, waiting up to `busy` for it while another process holds it: an app started again on
/// other graphics ([`crate::graphics`]) starts beside the one ending, which still holds it.
pub fn bind(port: u16, busy: Duration) -> std::io::Result<TcpListener> {
let since = std::time::Instant::now();
loop {
match TcpListener::bind(("127.0.0.1", port)) {
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && since.elapsed() < busy => std::thread::sleep(Duration::from_millis(50)),
bound => return bound,
}
}
}
pub fn start(port: u16, busy: Duration, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>) {
let listener = match bind(port, busy) {
Ok(l) => l,
Err(e) => {
log::error!("control server: can't bind 127.0.0.1:{port}: {e} (another app may own it; pick another port)");
@@ -38,8 +83,8 @@ 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() {
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
spawn_connection(stream, queue.clone(), ctx.clone(), events.clone(), &mut workers, TIMEOUT);
}
});
if let Err(e) = spawned {
@@ -47,20 +92,41 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
}
}
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
fn spawn_connection(
stream: TcpStream,
queue: Queue,
ctx: CtxSlot,
events: Arc<EventLog>,
workers: &mut Vec<std::thread::JoinHandle<()>>,
timeout: Duration,
) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
return;
}
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
let _ = serve(stream, queue, ctx, timeout);
let _ = serve_with_events(stream, queue, ctx, events, timeout);
}) {
Ok(worker) => workers.push(worker),
Err(e) => log::error!("control connection thread: {e}"),
}
}
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
/// Serve a connection with a private event log (the tests; the app shares its session's log).
#[cfg(test)]
fn serve(out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
serve_with_events(out, queue, ctx, Arc::new(EventLog::default()), timeout)
}
fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(out.try_clone()?);
@@ -86,8 +152,18 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let id = v.get("id").cloned().unwrap_or(Value::Null);
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
// A poll waits on the shared log, not on a frame: it needs no UI thread and no
// request slot, and it works while the window is minimised.
if method == "events.poll" {
let reply = match event_poll(&events, &params) {
Ok(result) => json!({"id": id, "ok": true, "result": result}),
Err(error) => json!({"id": id, "ok": false, "error": error}),
};
writeln!(out, "{reply}")?;
continue;
}
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
@@ -95,20 +171,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match rx.recv_timeout(timeout) {
match wait_for_reply(&rx, &gate, timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
}
}
Err(e) => {
@@ -124,6 +202,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
Ok(())
}
/// `events.poll {after?, timeoutMs?, topics?, limit?}`: read the shared log, waiting up to 25 s.
fn event_poll(log: &EventLog, params: &Value) -> Result<Value, String> {
let after = params.get("after").and_then(Value::as_u64).unwrap_or(0);
let timeout = params.get("timeoutMs").and_then(Value::as_u64).unwrap_or(20_000).min(25_000);
let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 1000) as usize;
let topics = events::topics(params.get("topics"))?;
let result = log.poll(after, &topics, limit, Duration::from_millis(timeout));
serde_json::to_value(result).map_err(|e| format!("serialize events: {e}"))
}
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;
+151 -5
View File
@@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
serde_json::from_str(&line).unwrap()
}
/// Build a `ui.inspect` control request with a throwaway reply channel.
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
#[test]
@@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
worker.join().unwrap().unwrap();
}
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
/// timeout branch consumes that reply and leaves the state `Replied`.
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
let path = dir.join("shot.png");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel();
// The UI's final commit gets the gate first: the file and reply are both committed before the
// transport's timeout branch can look at the gate.
let committed = gate.commit(|| {
std::fs::write(&path, b"png").unwrap();
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
});
assert!(committed.is_some());
assert!(path.is_file());
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
assert_eq!(reply.id, json!(7));
assert!(reply.result.is_ok());
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
assert!(path.is_file(), "a winning commit keeps its file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
/// cancelled.
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel::<ControlReply>();
drop(tx);
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
@@ -131,6 +178,18 @@ fn idle_and_partial_frame_connections_expire() {
}
}
/// A failed accept is followed by a wait before the next one, so a listener that keeps failing
/// does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test]
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let pending = queue();
@@ -139,12 +198,26 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let mut clients = Vec::new();
for _ in 0..MAX_CONNECTIONS {
let (client, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
spawn_connection(
server,
pending.clone(),
ctx.clone(),
Arc::new(figcraft_engine::events::EventLog::default()),
&mut workers,
Duration::from_secs(5),
);
clients.push(client);
}
assert_eq!(workers.len(), MAX_CONNECTIONS);
let (mut extra, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
spawn_connection(
server,
pending.clone(),
ctx.clone(),
Arc::new(figcraft_engine::events::EventLog::default()),
&mut workers,
Duration::from_secs(5),
);
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
assert_eq!(workers.len(), MAX_CONNECTIONS);
for client in clients {
@@ -156,7 +229,7 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
std::thread::sleep(Duration::from_millis(1));
}
let (mut client, server) = pair();
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
spawn_connection(server, pending, ctx, Arc::new(figcraft_engine::events::EventLog::default()), &mut workers, Duration::from_secs(5));
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
@@ -190,6 +263,39 @@ fn a_client_that_stops_reading_cannot_hold_a_worker() {
worker.join().unwrap().unwrap();
}
#[test]
fn events_poll_is_answered_without_the_ui_queue() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let log = Arc::new(figcraft_engine::events::EventLog::default());
log.push("comment.added", "Doc", None, json!({"commentId": 7}));
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
client.write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":0,\"timeoutMs\":0}}\n").unwrap();
let mut reader = BufReader::new(client);
let r = reply(&mut reader);
assert_eq!(r["id"], 1);
assert_eq!(r["result"]["events"].as_array().unwrap().len(), 1);
assert_eq!(r["result"]["events"][0]["kind"], "comment.added");
assert_eq!(r["result"]["events"][0]["data"]["commentId"], 7);
assert_eq!(r["result"]["cursor"], 1);
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn a_comment_added_in_the_session_is_pollable() {
let mut session = figcraft_engine::Session::new();
let log = session.events.clone();
let id = session.execute("comment.add", &json!({"x": 10, "y": 20, "text": "Fix the padding"})).unwrap()["id"].clone();
let r = event_poll(&log, &json!({"after": 0, "timeoutMs": 0})).unwrap();
assert_eq!(r["events"].as_array().unwrap().len(), 1);
assert_eq!(r["events"][0]["kind"], "comment.added");
assert_eq!(r["events"][0]["data"]["commentId"], id);
}
#[test]
fn a_limit_sized_frame_is_accepted_at_eof() {
let (mut client, server) = pair();
@@ -215,6 +321,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
worker.join().unwrap().unwrap();
}
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
/// and answered, and a window that finally draws discards its expired command before
/// dispatch.
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
@@ -250,7 +359,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
r.state.cancel_if_open();
r
}));
let (mut client, server) = pair();
@@ -280,3 +389,40 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
worker.join().unwrap().unwrap();
}
}
#[test]
fn events_poll_rejects_an_unknown_topic_without_the_ui_queue() {
let (client, server) = pair();
let pending = queue();
let q = pending.clone();
let log = Arc::new(figcraft_engine::events::EventLog::default());
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
let mut reader = BufReader::new(client);
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"topics\":[\"nope\"],\"timeoutMs\":0}}\n").unwrap();
let r = reply(&mut reader);
assert_eq!(r["id"], 1);
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().is_some_and(|e| e.contains("unknown topic")), "{r}");
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn events_poll_reports_reset_for_a_cursor_ahead_of_the_log() {
let (client, server) = pair();
let log = Arc::new(figcraft_engine::events::EventLog::default());
log.push("comment.added", "Doc", None, json!({"commentId": 1}));
let q = queue();
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
let mut reader = BufReader::new(client);
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":2,\"timeoutMs\":0}}\n").unwrap();
let r = reply(&mut reader);
assert_eq!(r["result"]["reset"], true, "{r}");
assert!(r["result"]["events"].as_array().unwrap().is_empty(), "{r}");
assert_eq!(r["result"]["cursor"], 1);
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
+164 -15
View File
@@ -1,4 +1,4 @@
//! Which graphics API draws the window.
//! Which graphics API draws the window, and what happens when it can't.
//!
//! 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:
@@ -6,29 +6,178 @@
//! 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.
//!
//! Two shader compilers lost the device before the window opened ("Parent device is lost", #965,
//! #1438), both on wgpu's indirect-call validation shaders, which wgpu compiles with every device:
//! Direct3D 12 took the first `dxcompiler.dll` on the search path, another program's, and failed
//! on the missing `dxil.dll` beside it; Metal's Intel compiler aborts on them on older Macs. So
//! Direct3D 12 compiles with FXC, which is part of Windows, and the validation shaders are left
//! out: FigCraft draws nothing indirectly, so they would check nothing.
//!
//! When the graphics still fail before the window's first frame, the app starts again with the
//! next of its [`attempts`]: on Windows Vulkan, then OpenGL; elsewhere OpenGL; and last the first
//! backends again with lower requirements on the power-saving GPU (macOS has no OpenGL in wgpu, so
//! that is its only other try). The attempts are a fixed list, so this ends; the message the last
//! one shows names what was tried.
//!
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
//! systems other than Windows keep eframe's defaults.
//! is then the one attempt. `WGPU_DX12_COMPILER` and `WGPU_VALIDATION_INDIRECT_CALL` still choose
//! the compiler and the validation.
use std::sync::Arc;
use std::time::Duration;
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
use eframe::wgpu::Backends;
use eframe::wgpu::{self, Backends, Dx12Compiler, InstanceFlags, Limits, PowerPreference};
/// Which of the [`attempts`] this start is: set by the app itself when it starts again.
pub const ATTEMPT_ENV: &str = "FIGCRAFT_GRAPHICS_ATTEMPT";
/// Frames drawn before a failure below which the window was still starting up: the second frame
/// begins once the first was presented.
pub const STARTUP_FRAMES: u64 = 2;
/// 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,
/// The graphics APIs by name, in the order the startup message lists them.
const NAMES: [(Backends, &str); 4] =
[(Backends::DX12, "Direct3D 12"), (Backends::METAL, "Metal"), (Backends::VULKAN, "Vulkan"), (Backends::GL, "OpenGL")];
/// One way of starting the graphics.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Attempt {
/// The backends wgpu chooses from.
pub backends: Backends,
/// The power-saving GPU and only the device limits OpenGL ES 3 hardware has.
pub lower: bool,
}
/// The ways to start, in order: only `WGPU_BACKEND`'s backends (`requested`) when it is set;
/// otherwise [`WINDOWS`] on Windows, else eframe's `defaults`, then Vulkan on Windows, OpenGL
/// except on macOS, and those first backends again with lower requirements.
pub fn attempts(requested: Option<Backends>, windows: bool, macos: bool, defaults: Backends) -> Vec<Attempt> {
let normal = |backends| Attempt { backends, lower: false };
if let Some(requested) = requested {
return vec![normal(requested)];
}
let first = if windows { WINDOWS } else { defaults };
let mut list = vec![normal(first)];
if windows {
list.push(normal(Backends::VULKAN));
}
if !macos && first != Backends::GL {
list.push(normal(Backends::GL));
}
list.push(Attempt { backends: first, lower: true });
list
}
/// Which of `count` attempts this start is, from [`ATTEMPT_ENV`] (`value`): the first when it is
/// unset or not a number, the last when it is past the end.
pub fn current(value: Option<&str>, count: usize) -> usize {
value.and_then(|v| v.trim().parse().ok()).unwrap_or(0).min(count.saturating_sub(1))
}
/// The attempt to start again with after `attempt` of `count` ended: the next one, when the
/// graphics failed (`graphics_failed`) before the window's first frame and one is left.
pub fn next(attempt: usize, count: usize, graphics_failed: bool, frames: u64) -> Option<usize> {
let next = attempt + 1;
(graphics_failed && frames < STARTUP_FRAMES && next < count).then_some(next)
}
/// How long this start waits for its control port: one started again ([`ATTEMPT_ENV`]) starts
/// beside the app that is ending, which still holds it for a moment.
pub fn port_wait() -> Duration {
if std::env::var_os(ATTEMPT_ENV).is_some() { Duration::from_secs(5) } else { Duration::ZERO }
}
/// The graphics APIs `attempts` tried among those this build has (`built`), by name.
pub fn names(attempts: &[Attempt], built: Backends) -> String {
let tried = attempts.iter().fold(Backends::empty(), |all, a| all | a.backends) & built;
NAMES.iter().filter(|(b, _)| tried.contains(*b)).map(|(_, name)| *name).collect::<Vec<_>>().join(", ")
}
/// Set up `options` for `attempt` (see the module docs).
pub fn apply(options: &mut WgpuConfiguration, attempt: Attempt) {
let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup else { return };
let instance = &mut create.instance_descriptor;
instance.backends = attempt.backends;
instance.backend_options.dx12.shader_compiler = Dx12Compiler::from_env().unwrap_or(Dx12Compiler::Fxc);
instance.flags = instance.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
if attempt.lower {
create.power_preference = PowerPreference::from_env().unwrap_or(PowerPreference::LowPower);
create.device_descriptor = Arc::new(|adapter| wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
// The window's surface still needs textures as large as the adapter allows.
required_limits: Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
..Default::default()
});
}
}
/// 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);
/// The attempts of this run and which one it is.
pub struct Plan {
attempts: Vec<Attempt>,
current: usize,
}
impl Plan {
/// Set up `options` for this start's attempt.
pub fn configure(options: &mut WgpuConfiguration) -> Self {
let defaults = match &options.wgpu_setup {
WgpuSetup::CreateNew(create) => create.instance_descriptor.backends,
WgpuSetup::Existing(_) => Backends::all(),
};
let attempts = attempts(Backends::from_env(), cfg!(windows), cfg!(target_os = "macos"), defaults);
let current = current(std::env::var(ATTEMPT_ENV).ok().as_deref(), attempts.len());
if let Some(attempt) = attempts.get(current) {
apply(options, *attempt);
}
Self { attempts, current }
}
/// The window opened on `adapter`: which GPU draws is the first question in every report.
pub fn opened(&self, adapter: &wgpu::AdapterInfo) {
let text =
format!("graphics: drawing with {:?} on {} ({:?}, driver {})", adapter.backend, adapter.name.trim(), adapter.device_type, adapter.driver);
if self.current == 0 {
log::info!("{text}");
} else {
log::warn!("{text}, start {} of {}: {:?}", self.current + 1, self.attempts.len(), self.attempts.get(self.current));
}
}
/// After the window's run (`result`, after `frames` frames): start again with the next attempt
/// when the graphics failed while the window was starting up, otherwise say why it failed
/// ([`crate::startup::failed`], `quiet` as there).
pub fn finish(&self, result: eframe::Result, frames: u64, quiet: bool) -> eframe::Result {
let Err(error) = &result else { return result };
if let Some(next) = next(self.current, self.attempts.len(), matches!(error, eframe::Error::Wgpu(_)), frames) {
log::warn!("graphics: {error}; starting again with {:?}", self.attempts.get(next));
match restart(next) {
Ok(()) => return Ok(()),
Err(e) => log::error!("graphics: starting again failed: {e}"),
}
}
let last = self.current > 0 && self.current + 1 == self.attempts.len();
let tried = last.then(|| names(&self.attempts, wgpu::Instance::enabled_backend_features()));
crate::startup::failed(error, quiet, tried.as_deref());
result
}
}
/// Start the app again with the same arguments as attempt `attempt`. On Unix the new app replaces
/// this process (an AppImage keeps its files mounted), and this returns only on failure; elsewhere
/// it starts beside this one, which then exits.
fn restart(attempt: usize) -> std::io::Result<()> {
log::logger().flush();
let mut command = std::process::Command::new(std::env::current_exe()?);
command.args(std::env::args_os().skip(1)).env(ATTEMPT_ENV, attempt.to_string());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
Err(command.exec())
}
#[cfg(not(unix))]
command.spawn().map(|_| ())
}
+132 -16
View File
@@ -1,32 +1,148 @@
//! 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.
//! The graphics attempts: no Vulkan on Windows at first (a faulting Vulkan driver crashed the app
//! as its window opened), another backend and then lower requirements when the graphics fail
//! before the first frame (#965, #1438), and never more starts than attempts.
use eframe::wgpu::Backends;
use std::time::Duration;
use crate::graphics::backends;
use eframe::egui_wgpu::{WgpuConfiguration, WgpuError, WgpuSetup, WgpuSetupCreateNew};
use eframe::wgpu::{Backends, Dx12Compiler, InstanceFlags, PowerPreference};
use crate::control_server::bind;
use crate::graphics::{ATTEMPT_ENV, Attempt, Plan, STARTUP_FRAMES, apply, attempts, current, names, next, port_wait};
/// 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");
const DX12_GL: Backends = Backends::DX12.union(Backends::GL);
const fn normal(backends: Backends) -> Attempt {
Attempt { backends, lower: false }
}
const fn lower(backends: Backends) -> Attempt {
Attempt { backends, lower: true }
}
#[test]
fn wgpu_backend_names_the_backend_on_every_system() {
for windows in [true, false] {
fn windows_starts_on_direct3d_then_vulkan_then_opengl_then_lower_requirements() {
let list = attempts(None, true, false, DEFAULTS);
assert_eq!(list, [normal(DX12_GL), normal(Backends::VULKAN), normal(Backends::GL), lower(DX12_GL)]);
assert!(!list[0].backends.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked at first");
}
#[test]
fn linux_falls_back_to_opengl_then_lower_requirements() {
assert_eq!(attempts(None, false, false, DEFAULTS), [normal(DEFAULTS), normal(Backends::GL), lower(DEFAULTS)]);
// OpenGL alone is tried once.
assert_eq!(attempts(None, false, false, Backends::GL), [normal(Backends::GL), lower(Backends::GL)]);
}
#[test]
fn macos_has_no_opengl_so_it_only_lowers_the_requirements() {
assert_eq!(attempts(None, false, true, DEFAULTS), [normal(DEFAULTS), lower(DEFAULTS)]);
assert_eq!(attempts(None, false, true, Backends::METAL), [normal(Backends::METAL), lower(Backends::METAL)]);
}
#[test]
fn wgpu_backend_names_the_one_attempt_on_every_system() {
for (windows, macos) in [(true, false), (false, false), (false, true)] {
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
assert_eq!(attempts(Some(asked), windows, macos, DEFAULTS), [normal(asked)], "windows: {windows}, macos: {macos}");
}
}
}
#[test]
fn other_systems_keep_eframes_defaults() {
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
fn the_attempt_comes_from_the_restart_and_stays_in_range() {
assert_eq!(current(None, 4), 0);
assert_eq!(current(Some("2"), 4), 2);
assert_eq!(current(Some(" 1 "), 4), 1);
assert_eq!(current(Some("9"), 4), 3, "past the end is the last");
assert_eq!(current(Some("-1"), 4), 0);
assert_eq!(current(Some("junk"), 4), 0);
assert_eq!(current(Some("1"), 0), 0);
}
#[test]
fn only_a_graphics_failure_while_starting_up_starts_again_and_never_past_the_last_attempt() {
assert_eq!(next(0, 4, true, 0), Some(1));
assert_eq!(next(2, 4, true, STARTUP_FRAMES - 1), Some(3));
assert_eq!(next(3, 4, true, 0), None, "the last attempt ends it");
assert_eq!(next(0, 1, true, 0), None, "WGPU_BACKEND's one attempt ends it");
assert_eq!(next(0, 4, false, 0), None, "not the graphics");
assert_eq!(next(0, 4, true, STARTUP_FRAMES), None, "the window ran");
// Following the restarts from the first start ends within the list.
for count in 1..6 {
let starts = std::iter::successors(Some(0), |&a| next(a, count, true, 0)).count();
assert_eq!(starts, count);
}
}
#[test]
fn the_message_names_the_apis_this_build_has() {
let windows = attempts(None, true, false, DEFAULTS);
assert_eq!(names(&windows, Backends::all()), "Direct3D 12, Vulkan, OpenGL");
let linux = attempts(None, false, false, DEFAULTS);
assert_eq!(names(&linux, Backends::VULKAN | Backends::GL), "Vulkan, OpenGL");
let macos = attempts(None, false, true, DEFAULTS);
assert_eq!(names(&macos, Backends::METAL), "Metal");
}
fn create_new(options: &WgpuConfiguration) -> Option<&WgpuSetupCreateNew> {
match &options.wgpu_setup {
WgpuSetup::CreateNew(create) => Some(create),
WgpuSetup::Existing(_) => None,
}
}
#[test]
fn every_attempt_compiles_with_fxc_and_leaves_the_indirect_validation_out() {
for attempt in [normal(Backends::DX12), lower(Backends::METAL)] {
let mut options = WgpuConfiguration::default();
let power = create_new(&options).map(|c| c.power_preference);
apply(&mut options, attempt);
let create = create_new(&options).expect("eframe creates the instance");
assert_eq!(create.instance_descriptor.backends, attempt.backends);
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
assert!(!create.instance_descriptor.flags.contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
}
if std::env::var_os("WGPU_POWER_PREF").is_none() {
let want = if attempt.lower { Some(PowerPreference::LowPower) } else { power };
assert_eq!(Some(create.power_preference), want);
}
}
}
#[test]
fn a_start_again_waits_for_the_control_port_the_ending_app_still_holds() {
let held = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("a free port");
let port = held.local_addr().expect("its address").port();
let busy = bind(port, Duration::ZERO).expect_err("the port is held");
assert_eq!(busy.kind(), std::io::ErrorKind::AddrInUse);
let release = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
drop(held);
});
assert!(bind(port, Duration::from_secs(10)).is_ok(), "bound once the other app let go");
release.join().expect("released");
if std::env::var_os(ATTEMPT_ENV).is_none() {
assert_eq!(port_wait(), Duration::ZERO, "a first start doesn't wait");
}
}
#[test]
fn a_failure_that_starts_nothing_again_ends_with_the_error() {
if std::env::var_os(ATTEMPT_ENV).is_some() {
return;
}
let mut options = WgpuConfiguration::default();
let plan = Plan::configure(&mut options);
assert!(plan.finish(Ok(()), 0, true).is_ok());
// A failure that isn't the graphics', and a graphics failure once the window ran: no restart.
assert!(plan.finish(Err(eframe::Error::AppCreation("no app".into())), 0, true).is_err());
let graphics = eframe::Error::Wgpu(WgpuError::NoSurfaceFormatsAvailable);
assert!(plan.finish(Err(graphics), STARTUP_FRAMES, true).is_err());
}
-112
View File
@@ -1,112 +0,0 @@
//! Request winit's frame only on Wayland compositors without xdg-decoration.
//! Desktop/session names cannot identify the backend of an individual window.
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, QueueHandle};
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
/// Never consume, duplicate or query that inherited connection: there may be no
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
pub fn display_socket() -> Option<PathBuf> {
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
return None;
}
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
let path = PathBuf::from(display);
if path.is_absolute() {
Some(path)
} else {
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
}
}
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
if !wayland {
log::debug!("window controls: non-Wayland window, retaining custom controls");
return false;
}
let server_decorations = socket.and_then(probe);
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
native_frame(wayland, server_decorations)
}
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
wayland && server_decorations == Some(false)
}
fn probe(socket: PathBuf) -> Option<bool> {
// The compositor must not be able to block startup on a registry roundtrip.
// On failure/timeout retain FigCraft's own working title bar and buttons.
let (send, receive) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("wayland-decorations".into())
.spawn(move || {
let _ = send.send(registry_has_decorations(socket));
})
.ok()?;
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
}
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
// A fresh connection leaves winit's event queue and inherited descriptors alone.
let stream = UnixStream::connect(socket).ok()?;
let connection = Connection::from_socket(stream).ok()?;
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
}
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_queue: &QueueHandle<Self>,
) {
}
}
#[cfg(test)]
mod tests {
#[test]
fn only_wayland_without_server_decorations_uses_the_frame() {
for (wayland, server_decorations, expected) in [
(false, None, false),
(false, Some(false), false),
(false, Some(true), false),
(true, Some(true), false),
(true, Some(false), true),
(true, None, false),
] {
assert_eq!(super::native_frame(wayland, server_decorations), expected);
}
}
#[test]
fn an_unavailable_display_keeps_the_custom_controls() {
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
}
#[test]
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
let start = std::time::Instant::now();
assert_eq!(super::probe(socket.clone()), None);
assert!(start.elapsed() < std::time::Duration::from_secs(2));
drop(listener);
let _ = std::fs::remove_file(socket);
Ok(())
}
}
+178 -46
View File
@@ -10,18 +10,22 @@
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
//! the command line are not. A root that isn't an existing folder stops the app at startup.
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit
// reports a removed UI Automation node. Catch build configurations that drop the workaround.
#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))]
compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive");
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
mod control_server;
mod gamepad;
mod graphics;
#[cfg(target_os = "linux")]
mod linux_window;
mod recovery;
mod services;
mod startup;
@@ -39,6 +43,8 @@ mod recovery_tests;
#[cfg(test)]
mod services_tests;
#[cfg(test)]
mod settings_dir_tests;
#[cfg(test)]
mod startup_tests;
use std::sync::{Arc, Mutex};
@@ -63,6 +69,9 @@ struct App {
}
impl eframe::App for App {
/// Native eframe logic callback: store the first context, drain control requests, run
/// guarded background work, poll macOS open events, handle close requests and title
/// changes, and schedule recovery repaints on the app clock.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
{
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -72,7 +81,9 @@ impl eframe::App for App {
}
let title = with_app(|fig| {
control_server::drain_queue(&self.queue, &mut fig.control_queue);
figcraft_ui_egui::control::drain_hidden(fig, ctx);
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
// It runs on logic-only callbacks too, so it never reads UI input.
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
#[cfg(target_os = "macos")]
apple_events::poll(&self.apple_events, fig, ctx);
// Closing the window asks about unsaved changes (and keeps the app running while other
@@ -87,8 +98,14 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
let now = ctx.input(|i| i.time);
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
// Recovery deadlines use the same monotonic clock as the app's background work: a
// logic-only tick leaves `ctx.input(i.time)` stale.
if let Some(wait) = with_app(|fig| {
let now = fig.now_secs();
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
})
.flatten()
{
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
@@ -107,14 +124,22 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
with_app(|fig| services::save_state(fig));
if let Some(r) = recovery::lock(&self.recovery).take() {
r.finish();
}
// With the app out of reach (busy), its recovery copies stay for the next start.
let copies = recovery::lock(&self.recovery).take();
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
// throws on the content view AccessKit restores (craft-libs#3, #781).
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&figcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
@@ -126,6 +151,12 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
if let Some(flag) = args.unknown.first() {
eprintln!(
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
);
std::process::exit(2);
}
let roots = match (
args.read_roots.is_empty() && args.write_roots.is_empty(),
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
@@ -141,21 +172,23 @@ fn main() -> eframe::Result {
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
let fonts_slot = ctx_slot.clone();
// Wakes the window (once it is there) from another thread.
let wake = |slot: &Arc<Mutex<Option<egui::Context>>>| {
let slot = slot.clone();
move || {
if let Some(c) = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}
};
let fonts_wake = wake(&ctx_slot);
std::thread::spawn(move || {
figcraft_text::scan_system_fonts();
if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
fonts_wake();
});
let mut session = Session::empty();
// Live sessions: the network threads wake the window when something arrives.
let wake_slot = ctx_slot.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {
if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}))));
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(wake(&ctx_slot)))));
for f in &args.files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
log::error!("{f}: {e}");
@@ -167,6 +200,7 @@ fn main() -> eframe::Result {
// The preferences first: they hold the online files a recovered document may be one of.
let recovery = recovery::Shared::default();
let svc = services::Desktop::load(recovery.clone());
svc.follow_system_dark(wake(&ctx_slot));
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
@@ -183,16 +217,15 @@ fn main() -> eframe::Result {
// default, never a build from source unless asked).
session.live.links = figcraft_collab_net::links::system();
session.invite_links_startup();
// 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 {
if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) {
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.set_startup_update_check(true);
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
@@ -203,7 +236,7 @@ fn main() -> eframe::Result {
}
let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));
if let Some(p) = port {
control_server::start(p, queue.clone(), ctx_slot.clone());
control_server::start(p, graphics::port_wait(), queue.clone(), ctx_slot.clone(), fig.session.events.clone());
}
let icon = services::window_icon();
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
@@ -214,18 +247,23 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
// Start frameless on Linux; the app creator checks the actual display backend
// before enabling a client-side frame. Other windows inherit that decision.
// Who draws the window frame is decided once, before the viewport is built, by the shared
// craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is
// kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the
// decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway,
// Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter —
// which draws no server-side frame — leaves it to the app's own custom title bar. This replaces
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
let graphics = graphics::Plan::configure(&mut options.wgpu_options);
// Counts the frames drawn, which tells a window that failed starting up from one that ran.
let window = ctx_slot.clone();
figcraft_ui_egui::windows::install(fig);
#[cfg(target_os = "linux")]
let linux_display = linux_window::display_socket();
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -235,11 +273,19 @@ fn main() -> eframe::Result {
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
#[cfg(target_os = "linux")]
if linux_window::needs_native_frame(_cc, linux_display) {
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
Box::new(|cc| {
if let Some(state) = &cc.wgpu_render_state {
graphics.opened(&state.adapter.get_info());
}
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
// winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
// app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
// sends the root window a close request instead, and the unsaved-changes question
// asks just as it does for the close button. Failure keeps winit's menu: the app
// runs, but ⌘Q ends it without asking.
#[cfg(target_os = "macos")]
if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") {
log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}");
}
Ok(Box::new(App {
queue,
@@ -247,20 +293,53 @@ fn main() -> eframe::Result {
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
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
let frames = window.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().map_or(0, egui::Context::cumulative_frame_nr);
// Start again on other graphics, or say why instead of quitting silently: release builds on
// Windows have no console.
graphics.finish(result, frames, port.is_some())
}
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs
/// start in a new file unless the preference asks for Home, which `--control` overrides: its
/// callers expect a document to act on.
fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
!first_run && (!start_on_home || control)
}
/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app
/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR`
/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the
/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager
/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one).
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT"));
title_bar_for(choice.into_frame())
}
/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`].
///
/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop`
/// means the desktop draws it, but FigCraft keeps each platform's existing look there:
/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged.
/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used
/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that.
/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only
/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame.
fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar {
use figcraft_ui_egui::TitleBar;
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
match frame {
craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset,
// Windows kept FigCraft's own title bar before this change; keep it.
craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom,
craft_window::Frame::Desktop => TitleBar::Native,
// The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override).
craft_window::Frame::App(_) => TitleBar::Custom,
}
}
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
@@ -272,13 +351,66 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T
}
}
#[cfg(all(test, target_os = "linux"))]
mod linux_window_tests {
#[cfg(test)]
mod title_bar_tests {
use figcraft_ui_egui::TitleBar;
/// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override)
/// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the
/// decorations so FigCraft draws its own.
#[test]
fn linux_starts_with_the_existing_frameless_title_bar() {
let bar = super::desktop_title_bar();
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
fn the_app_frame_maps_to_the_custom_title_bar() {
let layout = craft_window::Layout::default();
let bar = super::title_bar_for(craft_window::Frame::App(layout));
assert_eq!(bar, TitleBar::Custom);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(false));
}
/// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic
/// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's
/// native frame. The Linux case is the only behaviour change: KDE and the other
/// server-side-decoration compositors now keep their native frame.
#[test]
fn the_desktop_frame_is_mapped_per_platform() {
let bar = super::title_bar_for(craft_window::Frame::Desktop);
if cfg!(target_os = "macos") {
assert_eq!(bar, TitleBar::Inset);
} else if cfg!(target_os = "windows") {
// Windows kept its own title bar before and must keep it now.
assert_eq!(bar, TitleBar::Custom);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(false));
} else {
assert_eq!(bar, TitleBar::Native);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(true));
}
}
/// macOS is untouched by this change: its inset title bar with the system traffic lights stays,
/// and `window_chrome` still asks for the full-size content view rather than toggling the
/// window decorations.
#[test]
#[cfg(target_os = "macos")]
fn macos_keeps_its_inset_title_bar() {
let bar = super::desktop_title_bar();
assert_eq!(bar, TitleBar::Inset);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.fullsize_content_view, Some(true));
assert_eq!(viewport.title_shown, Some(false));
}
}
#[cfg(test)]
mod start_tests {
use super::opens_new_file;
#[test]
fn the_home_preference_starts_on_home_except_for_control_callers() {
assert!(opens_new_file(false, false, false), "a new file by default");
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
assert!(opens_new_file(false, true, true), "--control still starts with a document");
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
}
}
+67 -28
View File
@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use figcraft_engine::branches::{needs_copy, recovery_copy, stamp};
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
@@ -120,8 +121,7 @@ impl Recovery {
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
let lock = lock_folder(&dir).ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
@@ -204,13 +204,14 @@ impl Recovery {
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
continue;
}
let doc = d.doc.clone();
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
let (doc, versions, branches) = recovery_copy(s, d);
// 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
// the server, the file's id, then the session, client id, last change and the secret the
// client id resumes with), 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 online =
s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret));
let job = move || {
if !write_atomic(&path, &doc, &versions, &branches) {
return false;
@@ -250,6 +251,17 @@ impl Recovery {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
pub fn quit(mut self, s: &Session, asked: bool) {
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
return self.finish();
}
self.write(s, true);
self.settle(s);
}
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
@@ -282,6 +294,17 @@ fn remove_kept(w: Waiting) {
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
/// How long the startup waits, once, for folder locks that may free themselves (#334).
#[cfg(unix)]
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
lock.try_lock()?;
Ok(lock)
}
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
/// file isn't recorded any more, or is open already): then it opens as any other copy.
@@ -291,33 +314,36 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
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});
// Absent in notes written before ids had secrets.
let secret = at.next().flatten().unwrap_or(0);
let params =
json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret});
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()
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
let Ok(mut f) = opts.open(&tmp) else { return false };
if std::io::Write::write_all(&mut f, &bytes).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
}
#[cfg(not(unix))]
{
if std::fs::write(&tmp, bytes).is_err() {
return false;
}
}
std::fs::rename(&tmp, path).is_ok()
}
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
@@ -351,6 +377,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
}
}
};
let (mut free, mut held) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
@@ -358,10 +385,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
continue;
}
// Still running (another window of the app), or not ours to take.
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
if lock.try_lock().is_err() {
continue;
match lock_folder(&path) {
Ok(lock) => free.push((path, lock)),
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
Err(_) => {}
}
}
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
// lock to child processes.
#[cfg(unix)]
if !held.is_empty() {
std::thread::sleep(LOCK_WAIT);
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
}
for (path, lock) in free {
let lock = Arc::new(lock);
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
+1 -1
View File
@@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
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_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "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");
+58
View File
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
let root = root("quitunasked");
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
let s = edited();
Recovery::start(&root).unwrap().quit(&s, false);
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1);
let _ = std::fs::remove_dir_all(&root);
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
for (s, asked) in [(edited(), true), (Session::new(), false)] {
Recovery::start(&root).unwrap().quit(&s, asked);
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
let _ = std::fs::remove_dir_all(&root);
}
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
#[cfg(unix)]
#[test]
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
let root = root("fork-window");
crashed(&edited(), &root);
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
// exec holds it, and free it shortly after.
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
lock.try_lock().unwrap();
let freed = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
drop(lock);
});
// The next start waits for that lock rather than reading the run as still going.
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
freed.join().unwrap();
let _ = std::fs::remove_dir_all(&root);
}
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
@@ -348,3 +388,21 @@ fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_made_while_a_version_is_previewed_is_of_the_file_not_the_version() {
let root = root("preview");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
// The one-rectangle version shows in place of the two-rectangle file.
s.execute("version.preview", &json!({"index": 0})).unwrap();
assert_eq!(rectangles(s.doc().unwrap()), 1, "the preview shows");
assert!(s.ds().unwrap().is_dirty(), "the file itself still has unsaved changes");
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let next = crash_and_recover(r, &root);
assert_eq!(rectangles(next.doc().unwrap()), 2, "the file comes back, not the version shown");
assert_eq!(next.docs[0].versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
let _ = std::fs::remove_dir_all(&root);
}
+216 -13
View File
@@ -2,8 +2,8 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
@@ -11,25 +11,72 @@ use serde_json::{Value, json};
pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
egui_ctx: Option<egui::Context>,
/// Where the preferences live ([`config_dir`]).
dir: Option<PathBuf>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
/// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light.
dark: Arc<AtomicU8>,
/// The system's interface language (read at startup).
language: Option<String>,
/// Recovery copies of unsaved files (written at once after a panic).
recovery: crate::recovery::Shared,
/// The folder of the last file or folder a dialog picked, where the next one starts.
last_folder: Arc<Mutex<Option<PathBuf>>>,
}
/// The settings folder's name in the platform folder, as it always was (the suite keeps existing
/// folder names), and craft-settings' app id: `FIGCRAFT_CONFIG_DIR` names another folder.
const APP_ID: &str = "FigCraft";
/// Where the preferences, recovery copies and installed plugins live ([`settings_dir`]).
pub fn config_dir() -> Option<PathBuf> {
settings_dir(&craft_settings::Probe { platform_config_dir: platform_dir(), ..Default::default() })
}
/// The platform's config folder, read from the environment as before: `%APPDATA%` on Windows,
/// so a test's own `APPDATA` keeps the app away from the user's settings.
fn platform_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"));
#[cfg(target_os = "windows")]
let base = std::env::var_os("APPDATA").map(PathBuf::from);
#[cfg(all(unix, not(target_os = "macos")))]
let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")));
base.map(|b| b.join("FigCraft"))
base
}
/// The settings folder by the suite's rules (craft-settings), from what `probe` sees:
/// `FIGCRAFT_CONFIG_DIR` (set but empty: none, nothing read or written); a `portable` file next
/// to the executable (next to the `.app` on macOS), which puts them in `config/` beside it; else
/// `FigCraft` in the platform folder, where they always were. A portable folder without
/// preferences yet starts with a copy of the platform folder's, so an existing copy that becomes
/// portable keeps its settings; the original stays for an installed app. No platform folder: none.
pub(crate) fn settings_dir(probe: &craft_settings::Probe) -> Option<PathBuf> {
use craft_settings::{Location, Portable, Rule};
let resolved = craft_settings::paths::resolve(APP_ID, &Portable::DEFAULT, &Location::Auto, probe);
match (&resolved.rule, &resolved.dir, &probe.platform_config_dir) {
(Rule::Portable, Some(dir), Some(platform)) => seed(dir, &platform.join(APP_ID)),
// The current directory instead: settings would follow wherever the app was started.
(Rule::Fallback, ..) => return None,
_ => {}
}
resolved.dir
}
/// Copy `from`'s preferences into the folder `to` when it has none (owner-only, as written).
fn seed(to: &Path, from: &Path) {
let (target, source) = (to.join(STATE), from.join(STATE));
if target.exists() || !source.is_file() {
return;
}
let copied = std::fs::read(&source).and_then(|bytes| std::fs::create_dir_all(to).and_then(|()| write_private(&target, &bytes)));
match copied {
Ok(()) => log::info!("preferences: copied {} into the portable folder {}", source.display(), to.display()),
Err(e) => log::warn!("preferences: couldn't copy them into the portable folder {}: {e}", to.display()),
}
}
impl Desktop {
@@ -54,7 +101,33 @@ impl Desktop {
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN));
Self {
clipboard: arboard::Clipboard::new().ok(),
egui_ctx: None,
dir,
prefs,
reduce_motion,
dark,
language: system_language(),
recovery,
last_folder: Arc::default(),
}
}
/// winit reports no system theme on Linux and the BSDs: there the freedesktop colour scheme
/// answers instead, read once and then watched in the background, calling `wake` when it
/// changes (an idle window draws no frames). Elsewhere this does nothing.
pub fn follow_system_dark(&self, wake: impl Fn() + Send + 'static) {
#[cfg(all(unix, not(target_os = "macos")))]
if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({
let dark = self.dark.clone();
move || watch_system_dark(&dark, &wake)
}) {
log::info!("colour scheme: can't start the watcher: {e}");
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
let _ = wake;
}
}
@@ -136,6 +209,71 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light.
const DARK_UNKNOWN: u8 = 0;
const DARK_DARK: u8 = 1;
const DARK_LIGHT: u8 = 2;
/// The desktop's colour scheme through the freedesktop settings portal
/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light, 0 no preference, which is
/// light), read once and then watched for changes, each one calling `wake`. Without a session bus
/// or a portal the scheme stays unknown and the UI keeps its light fallback.
#[cfg(all(unix, not(target_os = "macos")))]
fn watch_system_dark(dark: &AtomicU8, wake: &dyn Fn()) {
use zbus::zvariant::Value;
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const APPEARANCE: &str = "org.freedesktop.appearance";
// `Read` (portals before 1.17, which lack `ReadOne`) wraps the value in one more variant.
let scheme = |v: &Value| match v {
Value::U32(x) => *x,
Value::Value(inner) => match **inner {
Value::U32(x) => x,
_ => 0,
},
_ => 0,
};
let store = |v: u32| {
let new = match v {
1 => DARK_DARK,
2 => DARK_LIGHT,
_ => DARK_UNKNOWN,
};
if dark.swap(new, Ordering::Relaxed) != new {
wake();
}
};
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else {
log::info!("colour scheme: no session bus: the system theme isn't followed");
return;
};
let read = |method: &str| -> zbus::Result<u32> {
let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), method, &(APPEARANCE, "color-scheme"))?;
let body = reply.body();
Ok(scheme(&body.deserialize::<Value>()?))
};
match read("ReadOne").or_else(|_| read("Read")) {
Ok(v) => store(v),
Err(e) => log::info!("colour scheme: can't read the system setting: {e}"),
}
let watch = || -> zbus::Result<()> {
let rule =
zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build();
for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? {
let msg = msg?;
let body = msg.body();
let Ok((namespace, key, value)) = body.deserialize::<(String, String, Value<'_>)>() else { continue };
if namespace == APPEARANCE && key == "color-scheme" {
store(scheme(&value));
}
}
Ok(())
};
if let Err(e) = watch() {
log::info!("colour scheme: can't watch the system setting: {e}");
}
}
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
fn reveal(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
@@ -167,10 +305,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
pub fn save_state(app: &FigApp) {
if let Some(dir) = config_dir() {
write_prefs(&dir, &app.session.prefs);
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
if let Some(dir) = dir {
write_prefs(dir, &app.session.prefs);
}
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
if let Some(r) = recovery {
r.quit(&app.session, app.ui.quit);
}
drop(std::mem::take(&mut app.session.live));
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
@@ -223,6 +369,9 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
#[cfg(target_os = "macos")]
const MACOS_PASTEBOARD_PNG: &str = "public.png";
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
#[cfg(all(unix, not(target_os = "macos")))]
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
@@ -254,25 +403,66 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
}
}
/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder
/// picked itself.
pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option<PathBuf> {
let folder = if is_folder { Some(path) } else { path.parent() };
folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf)
}
/// The remembered folder, unless it is gone since: then the system's own choice.
pub(crate) fn start_folder(last: Option<PathBuf>) -> Option<PathBuf> {
last.filter(|f| f.is_dir())
}
impl Desktop {
/// A dialog that starts in the last folder used. The Linux portal does not remember it between
/// dialogs as macOS and Windows do.
fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog {
let dialog = dialog(title);
match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) {
Some(folder) => dialog.set_directory(folder),
None => dialog,
}
}
}
/// `pick`, with the folder of what it picks kept in `last` for the next dialog.
pub(crate) fn remembering(last: Arc<Mutex<Option<PathBuf>>>, pick: DialogFuture, is_folder: bool) -> DialogFuture {
Box::pin(async move {
let path = pick.await?;
if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) {
*last = Some(folder);
}
Some(path)
})
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
let mut dialog = dialog(title).set_file_name(name);
let mut dialog = self.dialog_in_last_folder(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
let pick = picked(dialog.save_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
let pick = picked(self.dialog_in_last_folder(title).pick_folder());
remembering(self.last_folder.clone(), pick, true)
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
let _ = c.set_text(s.to_string());
if let Some(ctx) = &self.egui_ctx {
ctx.copy_text(s.to_string());
}
}
fn set_egui_context(&mut self, ctx: &egui::Context) {
self.egui_ctx = Some(ctx.clone());
}
fn clipboard_get_text(&mut self) -> Option<String> {
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
}
@@ -293,6 +483,12 @@ impl Services for Desktop {
}
Some((w, h, img.bytes.into_owned()))
}
#[cfg(target_os = "macos")]
fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
let board = objc2_app_kit::NSPasteboard::generalPasteboard();
let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?;
(png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec())
}
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
@@ -316,6 +512,13 @@ impl Services for Desktop {
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
fn system_dark(&self) -> Option<bool> {
match self.dark.load(Ordering::Relaxed) {
DARK_DARK => Some(true),
DARK_LIGHT => Some(false),
_ => None,
}
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
+60
View File
@@ -82,3 +82,63 @@ fn preferences_are_readable_by_their_owner_only() {
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
/// A live link that only records the sessions it was told to leave.
struct Leaves(Arc<Mutex<Vec<u64>>>);
impl figcraft_engine::live::LiveLink for Leaves {
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
fn leave(&mut self, session: u64) {
self.0.lock().unwrap().push(session);
}
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
Vec::new()
}
}
#[test]
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
let dir = root("shutdown");
let mut fig = start(&dir);
let left = Arc::new(Mutex::new(Vec::new()));
fig.session.live.install(Box::new(Leaves(left.clone())));
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
fig.session.prefs.user_name = "Ada".into();
crate::services::shutdown(&mut fig, Some(&dir), None);
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
assert!(!fig.session.live.available(), "nothing is left to send on");
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself.
#[test]
fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
use crate::services::{folder_to_remember, remembering, start_folder};
use std::path::PathBuf;
let dir = root("last-folder");
let file = dir.join("Poster.figcraft");
assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder");
assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself");
assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder");
let last = Arc::new(Mutex::new(None::<PathBuf>));
let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false);
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file)));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered");
let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false);
assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder");
assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone()));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
}
+101
View File
@@ -0,0 +1,101 @@
//! Where the settings live (#1320): a portable copy keeps them next to the executable, taking the
//! ones it had before; without a marker they stay in the platform folder they always used. Every
//! folder here is a temporary one: the probe sees no real environment, executable or profile.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use craft_settings::Probe;
use figcraft_engine::Prefs;
use crate::services::{Desktop, settings_dir, write_prefs};
/// A fresh temporary folder with an `app` folder (the executable's) and an `appdata` folder (the
/// platform's config folder).
fn root(name: &str) -> PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-settings-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
std::fs::create_dir_all(r.join("app")).unwrap();
std::fs::create_dir_all(r.join("appdata")).unwrap();
r
}
/// What the rules see: the executable in `root/app`, the platform folder `platform`, and
/// `FIGCRAFT_CONFIG_DIR` set to `config_dir` when given.
fn probe(root: &Path, platform: Option<PathBuf>, config_dir: Option<&'static str>) -> Probe {
Probe {
env: Box::new(move |name| config_dir.filter(|_| name == "FIGCRAFT_CONFIG_DIR").map(OsString::from)),
exe_dir: Some(root.join("app")),
platform_config_dir: platform,
}
}
/// Preferences with a name to recognise them by.
fn prefs(name: &str) -> Prefs {
Prefs { user_name: name.into(), ..Prefs::default() }
}
/// The user name of the preferences the app starts with from `dir`.
fn loaded_name(dir: PathBuf) -> Option<String> {
Desktop::load_from(Some(dir), Arc::new(Mutex::new(None))).prefs.map(|p| p.user_name)
}
#[test]
fn without_a_marker_the_settings_stay_where_they_always_were() {
let root = root("installed");
let old = root.join("appdata").join("FigCraft");
write_prefs(&old, &prefs("Ada"));
let dir = settings_dir(&probe(&root, Some(root.join("appdata")), None));
assert_eq!(dir, Some(old.clone()), "the folder every earlier version used");
assert_eq!(loaded_name(old), Some("Ada".into()), "an existing user's preferences are read");
assert!(!root.join("app").join("config").exists(), "nothing is written next to the executable");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_portable_marker_keeps_the_settings_next_to_the_executable_and_takes_the_old_ones() {
let root = root("portable");
let old = root.join("appdata").join("FigCraft");
write_prefs(&old, &prefs("Ada"));
std::fs::write(root.join("app").join("portable"), "").unwrap();
let portable = root.join("app").join("config");
let dir = settings_dir(&probe(&root, Some(root.join("appdata")), None));
assert_eq!(dir, Some(portable.clone()));
assert_eq!(loaded_name(portable.clone()), Some("Ada".into()), "the copy starts with the settings it had");
assert_eq!(loaded_name(old.clone()), Some("Ada".into()), "the installed app's stay where they were");
// Later starts keep the portable copy's own settings.
write_prefs(&portable, &prefs("Grace"));
assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), None)), Some(portable.clone()));
assert_eq!(loaded_name(portable), Some("Grace".into()), "never overwritten by the old ones again");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_new_portable_copy_starts_without_settings() {
let root = root("fresh");
std::fs::write(root.join("app").join("portable"), "").unwrap();
let portable = root.join("app").join("config");
assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), None)), Some(portable.clone()));
assert_eq!(loaded_name(portable), None, "a first run");
// No platform folder at all: the marker still decides.
assert_eq!(settings_dir(&probe(&root, None, None)), Some(root.join("app").join("config")));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn figcraft_config_dir_names_the_folder_and_empty_means_none() {
let root = root("env");
std::fs::write(root.join("app").join("portable"), "").unwrap();
let named = settings_dir(&probe(&root, Some(root.join("appdata")), Some("elsewhere")));
assert_eq!(named, Some(PathBuf::from("elsewhere")), "ahead of the marker");
assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), Some(""))), None, "isolated: nothing read or written");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn without_a_platform_folder_there_are_no_settings() {
let root = root("nowhere");
assert_eq!(settings_dir(&probe(&root, None, None)), None, "not the current directory");
let _ = std::fs::remove_dir_all(&root);
}
+4 -3
View File
@@ -5,10 +5,11 @@
//! 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) {
/// keep the caller waiting, and it reads standard error), show it in a message box. `tried`: the
/// graphics APIs the app started again with before this failure ([`crate::graphics::Plan::finish`]).
pub fn failed(error: &eframe::Error, quiet: bool, tried: Option<&str>) {
// 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());
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some(), tried);
// Standard error even when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{text}");
+24 -8
View File
@@ -2,34 +2,50 @@
//! report it, instead of the app quitting silently (#261).
use figcraft_ui_egui::dialogs::window_failed as message;
use figcraft_ui_egui::i18n::tr;
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.";
const DRIVER_HINT: &str = if cfg!(windows) {
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
} else {
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
};
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
let text = message(ERROR, false);
let text = message(ERROR, false, None);
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}");
let text = message(ERROR, false, None);
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains("unsetting"), "{text}");
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
}
#[cfg(windows)]
#[test]
fn on_windows_it_shows_how_to_start_with_opengl() {
let text = message(ERROR, false);
let text = message(ERROR, false, None);
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}");
let text = message(ERROR, true, None);
assert!(text.contains(tr(BACKEND_HINT)), "{text}");
assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}");
}
#[test]
fn after_every_attempt_failed_it_names_what_was_tried_and_no_other_api() {
let text = message(ERROR, false, Some("Direct3D 12, Vulkan, OpenGL"));
assert!(text.contains(ERROR), "the last error: {text}");
assert!(text.contains("Direct3D 12, Vulkan, OpenGL"), "what was tried: {text}");
assert!(!text.contains(tr(DRIVER_HINT)), "every API was tried already: {text}");
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
}
+4
View File
@@ -4,3 +4,7 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+48
View File
@@ -24,6 +24,10 @@ display_name = "VanTan"
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
[people.SiadeLucas]
real_name = "Siade Lucas"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
@@ -37,6 +41,11 @@ real_name = "Justin John"
[people.Losminek]
[people.alrifatsabbir]
real_name = "Al Rifat Sabbir"
display_name = "Al Rifat Sabbir"
sync_github_name = true
[people.RevDonz]
[people.Nael-Nathanael]
@@ -53,3 +62,42 @@ sync_github_name = false
[people.krinry]
display_name = "krinry"
sync_github_name = true
[people.pechnikov2026-star]
sync_github_name = true
[people.MortenHusted]
sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
[people.mojtabajafarzadeh]
real_name = "Mojtaba Jafarzadeh"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.leandrodslv]
sync_github_name = true
[people.mateusvillain]
sync_github_name = true
[people.culiulemon]
sync_github_name = true
[people.StrajnarFilip]
display_name = "Filip Strajnar"
[people.Carbocode]
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
+61
View File
@@ -0,0 +1,61 @@
//! A hostile server can't grow a guest's memory without bound (#1121): the presence waiting for
//! the UI and the people a connection has seen hold at most [`MAX_PEERS`] others; past it,
//! someone new is left out, and someone leaving (or the UI taking the presence) makes room.
use std::sync::{Arc, Mutex};
use figcraft_collab::message::{Peer, Presence, ServerMsg};
use figcraft_engine::live::{Event, MAX_PEERS};
use crate::guest::GuestConfig;
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::join::{Seen, take};
fn inbox() -> Inbox {
Inbox::new(Arc::new(|| {}))
}
#[test]
fn presence_from_more_people_than_the_cap_is_left_out() {
let inbox = inbox();
let over = MAX_PEERS as u32 + 500;
for id in 0..over {
inbox.presence(id, Presence::default());
}
// Someone already waiting is still updated.
let moved = Presence { selection: Some(Vec::new()), ..Presence::default() };
inbox.presence(3, moved.clone());
let out = inbox.drain();
assert_eq!(out.len(), MAX_PEERS);
assert!(out.iter().all(|e| matches!(e, Event::Presence(id, _) if (*id as usize) < MAX_PEERS)));
assert!(out.iter().any(|e| matches!(e, Event::Presence(3, p) if *p == moved)));
// Taken by the UI: room again.
inbox.presence(over, Presence::default());
assert!(matches!(inbox.drain().as_slice(), [Event::Presence(id, _)] if *id == over));
}
#[test]
fn joins_past_the_cap_are_left_out_until_someone_leaves() {
let inbox = inbox();
let cfg = GuestConfig::new(Invite::new("127.0.0.1:47900", "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv"), "Ada".into());
let resume = Mutex::new(None);
let mut seen = Seen::default();
let mut send = |m: ServerMsg| {
take(m, &cfg, &inbox, &mut seen, &resume, &|| {});
};
let joined = |id: u32| ServerMsg::PeerJoined(Peer { client_id: id, name: format!("P{id}"), color: String::new() });
let over = MAX_PEERS as u32 + 500;
for id in 0..over {
send(joined(id));
}
let count = |events: &[Event]| events.iter().filter(|e| matches!(e, Event::PeerJoined(_))).count();
assert_eq!(count(&inbox.drain()), MAX_PEERS, "joins past the cap reach the UI");
// Someone already here may be renamed; someone left out stays out until someone leaves.
send(joined(5));
send(joined(over));
assert!(matches!(inbox.drain().as_slice(), [Event::PeerJoined(p)] if p.client_id == 5));
send(ServerMsg::PeerLeft { client_id: 7 });
send(joined(over));
assert!(matches!(inbox.drain().as_slice(), [Event::PeerLeft(7), Event::PeerJoined(p)] if p.client_id == over));
}
+11 -3
View File
@@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let mut late = session("Cy");
late.execute("collab.join", &json!({"code": shown})).unwrap();
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
// The port is free again: another program's host may have taken it, and that host refuses
// the code's secret.
let label = late.live().status.label();
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
}
#[test]
@@ -103,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
assert!(host.live().peers.is_empty());
// A typing mistake is caught before anything connects.
let mut typo: Vec<char> = local.code().chars().collect();
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
// The last symbol contains only checksum bits. Changing payload bits can collide with
// the eight-bit checksum, so it does not guarantee a malformed code.
let last = typo.last_mut().unwrap();
*last = if *last == 'A' { 'B' } else { 'A' };
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
assert!(e.contains("typing mistake"), "{e}");
// A view-only code doesn't exist here, and says where it does.
@@ -141,7 +147,9 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
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();
// read() returns Result<Message>, not a lock guard; this test server deliberately
// ignores whatever the client sent first.
let _ = ws.read(); // NOSONAR
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);
+162 -39
View File
@@ -2,21 +2,30 @@
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every
//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with
//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the
//! WebSocket's events and wakes it at [`Driver::deadline`].
//!
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
//! code (or can't be reached) ends the join with the reason.
//!
//! Publishing ([`Driver::publish`]) and the requests sent in the background
//! ([`Driver::asking`]) first ask a server things on managing connections of their own (see
//! `exchange.rs`), each waiting for its answers as long as a large upload may take; a publish then
//! joins the new file.
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::exchange::{self, Exchange, Then};
use crate::inbox::Inbox;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::invite::Endpoint;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::requests::Publish;
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
@@ -36,13 +45,15 @@ struct Conn {
opened: bool,
welcomed: bool,
seen: Seen,
last_sent: Instant,
/// The last ping (or the connection's start): one goes out every keep-alive interval, whatever
/// else is sent, since presence gets no answer and only what the host sends counts as heard.
last_ping: Instant,
last_heard: Instant,
}
impl Conn {
fn new(now: Instant) -> Self {
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
}
}
@@ -60,13 +71,14 @@ pub(crate) struct Driver<S: Socket> {
pending: Option<Presence>,
throttle: Throttle<Presence>,
reconnect_at: Option<Instant>,
/// What this connection asks a server (a managing one), and what follows its answers.
asking: Option<(Box<Exchange>, Then)>,
done: bool,
}
impl<S: Socket> Driver<S> {
/// Start joining (progress arrives in `inbox`).
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
let mut d = Self {
fn new(sock: S, cfg: GuestConfig, inbox: Inbox) -> Self {
Self {
sock,
cfg,
inbox,
@@ -77,44 +89,132 @@ impl<S: Socket> Driver<S> {
pending: None,
throttle: Throttle::new(PRESENCE_INTERVAL),
reconnect_at: None,
asking: None,
done: false,
};
}
}
/// Start joining (progress arrives in `inbox`).
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
let mut d = Self::new(sock, cfg, inbox);
d.inbox.push(Event::Status(Status::Connecting));
d.connect(now);
d
}
/// Start with what `next` asks a server (a background request): the events it ends in go to
/// `inbox`, then the join it ends in, if any, as `cfg` says.
pub(crate) fn asking(sock: S, cfg: GuestConfig, next: exchange::Next, inbox: Inbox, now: Instant) -> Self {
let mut d = Self::new(sock, cfg, inbox);
d.follow(next, now);
d
}
/// Start publishing `job`, then join the new file as `cfg` says (its invite is the new file's).
pub(crate) fn publish(sock: S, cfg: GuestConfig, job: Publish, inbox: Inbox, now: Instant) -> Self {
inbox.push(Event::Status(Status::Connecting));
Self::asking(sock, cfg, exchange::publish(job), inbox, now)
}
/// The session is over (left, refused, ended or given up): nothing more will happen.
pub(crate) fn done(&self) -> bool {
self.done
}
fn connect(&mut self, now: Instant) {
self.reconnect_at = None;
self.conn = Some(Conn::new(now));
if let Err(e) = self.sock.open(&self.cfg.invite.endpoint()) {
self.end(Outcome::Retry(e), now);
/// Ask the next server, or tell the UI and join (or stop).
fn follow(&mut self, next: exchange::Next, now: Instant) {
match next {
exchange::Next::Ask(ex, then) => {
self.asking = Some((ex, then));
self.connect(now);
}
exchange::Next::Done(events, join) => {
for e in events {
self.inbox.push(e);
}
match join {
Some(invite) => {
self.cfg.invite = invite;
self.connect(now);
}
None => self.done = true,
}
}
}
}
/// The connection is open: say hello (or ask about the join code first).
/// The server this connection is to.
fn server(&self) -> &Endpoint {
self.asking.as_ref().map_or(&self.cfg.invite.server, |(ex, _)| &ex.server)
}
fn connect(&mut self, now: Instant) {
self.reconnect_at = None;
self.conn = Some(Conn::new(now));
if let Err(e) = self.sock.open(&self.server().url()) {
self.dropped(e, now);
}
}
/// The connection broke for this reason: a managing one fails, a session's tries again.
fn dropped(&mut self, why: String, now: Instant) {
if self.asking.is_some() { self.answered(Err(why), now) } else { self.end(Outcome::Retry(why), now) }
}
/// The managing connection is over (with why it failed): close it, then what follows.
fn answered(&mut self, r: Result<(), String>, now: Instant) {
let Some((ex, then)) = self.asking.take() else { return };
if self.conn.take().is_some() {
self.sock.close();
}
let answers = match r {
Ok(()) => Ok(ex.answers()),
Err(e) => Err(ex.failed(e)),
};
self.follow(then(answers), now);
}
/// The connection is open: say hello (or ask about the join code first, or open what is asked).
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 = 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),
let hello = match (&self.asking, &self.cfg.code) {
(Some((ex, _)), _) => ex.opening().clone(),
(None, Some(code)) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
(None, None) => hello(&self.cfg, &self.resume),
};
if !self.write(&hello, now) {
self.end(Outcome::Retry("the connection dropped".into()), now);
if !self.write(&hello) {
self.dropped("the connection dropped".into(), now);
}
}
/// A binary message from the host.
/// A binary message on a managing connection: the next request, or what follows the answers.
fn answer(&mut self, bytes: &[u8], now: Instant) {
let Some((ex, _)) = self.asking.as_mut() else { return };
let next = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
Ok(m) => ex.take(m),
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
};
let done = ex.done();
match next {
Err(e) => self.answered(Err(e), now),
Ok(Some(req)) if !self.write(&req) => self.answered(Err("the connection to the server dropped".into()), now),
Ok(_) if done => self.answered(Ok(()), now),
Ok(_) => {}
}
}
/// A binary message from the host (or the server asked).
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
let Some(c) = self.conn.as_mut() else { return };
c.last_heard = now;
if self.asking.is_some() {
return self.answer(bytes, now);
}
if bytes.len() > self.cfg.limits.max_server_message {
return self.end(too_large(&self.cfg.limits), 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() => {
@@ -149,12 +249,23 @@ impl<S: Socket> Driver<S> {
if self.conn.is_none() {
return;
}
if self.asking.is_some() {
return self.answered(Err("the server sent a text message".into()), now);
}
self.end(Outcome::Refused("the host sent a text message".into()), now);
}
/// The connection closed (`code` and `reason` from the close frame; 1006 when it just broke).
pub(crate) fn closed(&mut self, code: u16, reason: &str, now: Instant) {
let Some(opened) = self.conn.as_ref().map(|c| c.opened) else { return };
if self.asking.is_some() {
let why = match (opened, reason) {
(false, _) => format!("no answer from {}", self.server().display()),
(true, "") => "the server closed the connection".to_string(),
(true, r) => format!("the server closed the connection: {r}"),
};
return self.answered(Err(why), now);
}
let outcome = match closed(code, reason) {
Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())),
Outcome::Retry(_) if code == 1006 => Outcome::Retry("the connection dropped".into()),
@@ -170,7 +281,7 @@ impl<S: Socket> Driver<S> {
return;
}
if let Some(p) = self.throttle.offer(p, now)
&& !self.write(&ClientMsg::Presence(p), now)
&& !self.write(&ClientMsg::Presence(p))
{
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -183,7 +294,7 @@ impl<S: Socket> Driver<S> {
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
return;
}
if !self.write(&msg, now) {
if !self.write(&msg) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -199,11 +310,12 @@ impl<S: Socket> Driver<S> {
return None;
}
let l = &self.cfg.limits;
let conn = self.conn.as_ref().map(|c| {
if c.opened {
[Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
} else {
[Some(c.started + self.cfg.connect_timeout), None, None]
let conn = self.conn.as_ref().map(|c| match (c.opened, self.asking.is_some()) {
(false, _) => [Some(c.started + self.cfg.connect_timeout), None, None],
// A managing connection waits for each answer.
(true, true) => [Some(c.last_heard + l.request_reply_timeout), None, None],
(true, false) => {
[Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
}
});
earliest(conn.into_iter().flatten().chain([self.reconnect_at]))
@@ -220,36 +332,47 @@ impl<S: Socket> Driver<S> {
}
let l = self.cfg.limits;
let Some(c) = self.conn.as_ref() else { return };
let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
self.dropped(format!("no answer from {}", self.server().display()), now);
}
return;
}
if self.asking.is_some() {
if now >= last_heard + l.request_reply_timeout {
self.answered(Err("no answer from the server".into()), now);
}
return;
}
if now >= last_heard + l.idle_timeout {
return self.end(Outcome::Retry("the host stopped answering".into()), now);
}
let ok = match self.throttle.take_due(now) {
Some(p) => self.write(&ClientMsg::Presence(p), now),
None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
None => true,
};
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
if !ok {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
/// Send a ping; false when it couldn't go.
fn ping(&mut self, now: Instant) -> bool {
if let Some(c) = self.conn.as_mut() {
c.last_sent = now;
c.last_ping = now;
}
self.write(&ClientMsg::Ping)
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
}
/// The connection ended: close it, then stop or plan the reconnect.
fn end(&mut self, outcome: Outcome, now: Instant) {
// Left while asking a server: what it was asked stays done (a file uploaded stays there),
// nothing follows.
self.asking = None;
// 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() {
+44 -3
View File
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, Status};
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
@@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() {
d.tick(due);
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
let n = d.sock.sent.len();
// Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
// A ping every keep-alive interval from the connection's start, presence or not (the host
// answers, which counts as traffic).
let ping_at = d.deadline().unwrap();
assert_eq!(ping_at, due + Duration::from_secs(15));
assert_eq!(ping_at, t0 + Duration::from_secs(15));
d.tick(ping_at - ms(1));
assert_eq!(d.sock.sent.len(), n, "not yet");
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
@@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() {
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
}
/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the
/// pings still go out, and the host's pongs keep the connection (before, each presence put the
/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection).
#[test]
fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
let t0 = Instant::now();
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
inbox.drain();
let mut answered = 0;
for i in 1..=900u32 {
let now = t0 + ms(100 * u64::from(i));
d.presence(cursor(f64::from(i)), now);
while let Some(due) = d.deadline().filter(|t| *t <= now) {
d.tick(due);
}
// The host answers every ping (and nothing else) at once.
let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping);
answered = d.sock.sent.len();
if pinged {
d.message(&encode(&ServerMsg::Pong), now);
}
}
assert!(!d.done());
assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected");
assert!(statuses(&inbox.drain()).is_empty());
let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count();
assert!(pings >= 5, "a ping every 15 s ({pings})");
}
#[test]
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
let t0 = Instant::now();
@@ -262,6 +294,15 @@ fn refusals_and_the_end_of_the_session_are_final() {
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
}
// A message over the size limit: the file is too large for this app, not refused by the host.
let inbox = Inbox::new(Arc::new(|| {}));
let mut cfg = GuestConfig::new(invite(), "Ada".into());
cfg.limits.max_server_message = 2 << 20;
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
d.opened(t0);
d.message(&vec![0; (2 << 20) + 1], t0);
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
}
#[test]
+236
View File
@@ -0,0 +1,236 @@
//! Requests to a server outside a session for the browser client, free of the browser so they run
//! (and are tested) on any target: an [`Exchange`] is one managing connection (its opening, then
//! requests answered in order, as `manage.rs` does on a blocking socket), and a [`Next`] says what
//! follows its answers: another exchange, or the events for the UI and the file to join. The
//! [`Driver`](crate::driver::Driver) runs them over its socket.
//!
//! Publishing ([`publish`]) uploads the document, makes the links and codes others join with when
//! it shares, then joins the new file with its edit link; [`background`] sends the hosting and
//! sharing requests the engine sends in the background (asking a server about its hosting,
//! starting and stopping a share).
use std::collections::VecDeque;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Manage, OpenHosting, PROTOCOL, ServerMsg};
use figcraft_engine::live::{Event, NetReply, NetRequest, Shared, Status};
use crate::invite::{Endpoint, Invite};
use crate::requests::{self, Publish, REFUSED};
/// One managing connection to a server: what opens it, then requests answered in order.
pub(crate) struct Exchange {
pub(crate) server: Endpoint,
open: ClientMsg,
requests: VecDeque<Admin>,
answers: Answers,
/// The server let the connection in (its first answer came).
ready: bool,
/// A request is out.
asked: bool,
}
/// What an exchange got: the server's hosting (opened without the key) and the answers to the
/// requests, in order.
#[derive(Default)]
pub(crate) struct Answers {
pub(crate) hosting: Option<HostInfo>,
pub(crate) replies: Vec<AdminReply>,
}
/// What follows an exchange's answers (or why it failed).
pub(crate) type Then = Box<dyn FnOnce(Result<Answers, String>) -> Next>;
/// What comes next.
pub(crate) enum Next {
/// Another exchange, on a connection of its own.
Ask(Box<Exchange>, Then),
/// Done: tell the UI these, then join the file this invite names (`None`: nothing to join).
Done(Vec<Event>, Option<Invite>),
}
impl Exchange {
/// Ask `server` `requests`: with its `key`, else (open hosting) as anyone, or as the owner of
/// `file` with its `owner` token.
pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec<Admin>) -> Self {
let open = match key {
Some(key) => ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() }),
None => ClientMsg::OpenHosting(OpenHosting {
protocol: PROTOCOL,
client_version: message::client_version(),
file: file.to_string(),
owner: owner.to_string(),
}),
};
Self { server, open, requests: requests.into(), answers: Answers::default(), ready: false, asked: false }
}
/// The connection's first message.
pub(crate) fn opening(&self) -> &ClientMsg {
&self.open
}
/// Every request is answered.
pub(crate) fn done(&self) -> bool {
self.ready && !self.asked && self.requests.is_empty()
}
pub(crate) fn answers(self) -> Answers {
self.answers
}
/// Why the exchange failed with `e`, as the app says it: a server older than open hosting
/// can't read the opening.
pub(crate) fn failed(&self, e: String) -> String {
if self.ready || !matches!(self.open, ClientMsg::OpenHosting(_)) { e } else { requests::hosting_error(&self.server, e) }
}
/// Take in one message from the server: the next request to send, if one is due; an error
/// ends the exchange.
pub(crate) fn take(&mut self, m: ServerMsg) -> Result<Option<ClientMsg>, String> {
match m {
ServerMsg::Refused { reason } => return Err(self.failed(format!("{REFUSED}{reason}"))),
ServerMsg::Pong => return Ok(None),
ServerMsg::Admin(r) if self.asked => self.answers.replies.push(*r),
ServerMsg::Admin(r) if !self.ready && matches!(self.open, ClientMsg::Manage(_)) => match *r {
AdminReply::Ready => self.ready = true,
other => return Err(format!("the server answered {other:?} instead of letting this connection manage files")),
},
ServerMsg::Hosting(info) if !self.ready && matches!(self.open, ClientMsg::OpenHosting(_)) => {
self.ready = true;
self.answers.hosting = Some(*info);
}
_ => return Err("the server sent something other than an answer".into()),
}
self.asked = false;
Ok(self.requests.pop_front().map(|r| {
self.asked = true;
ClientMsg::Admin(r)
}))
}
}
/// The UI's failure: why the file couldn't be published (it stays as it was).
fn not_published(e: &str) -> Next {
Next::Done(vec![Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })], None)
}
/// Publish `job` (see the module docs): [`Event::Hosting`] (published without the key),
/// [`Event::Published`] and, to share, [`Event::Shared`], then the join.
pub(crate) fn publish(job: Publish) -> Next {
let Publish { server, key, name, doc, share } = job;
let upload = match requests::upload(&name, &doc) {
Ok(r) => r,
Err(e) => return not_published(&e),
};
let ex = Exchange::new(server.clone(), key.as_deref(), "", "", vec![upload]);
Next::Ask(
Box::new(ex),
Box::new(move |answers| {
let (hosting, uploaded) = match answers {
Ok(a) => (a.hosting, requests::uploaded(&server, a.replies.into_iter().next())),
Err(e) => (None, Err(e)),
};
let (edit, published) = match uploaded {
Ok(done) => done,
Err(e) => return not_published(&e),
};
let mut events: Vec<Event> = hosting.map(Event::Hosting).into_iter().collect();
events.push(Event::Published(published.clone()));
if !share {
return Next::Done(events, Some(edit));
}
// The file's first links stay its owner's (this app joins with one): others get their
// own, which stopping takes away again.
let owner = published.owner.unwrap_or_default();
let then: LinksThen = Box::new(move |made| {
events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))));
Next::Done(events, Some(edit))
});
share_links(Sharing { server, key, file: published.file, owner, revoke: Vec::new(), create: true }, then)
}),
)
}
/// What [`share_links`] changes: who else gets into `file` (as `manage::share` does).
struct Sharing {
server: Endpoint,
key: Option<String>,
file: String,
owner: String,
revoke: Vec<String>,
create: bool,
}
/// What [`share_links`] hands the links to.
type LinksThen = Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>;
/// Revoke the links `s.revoke` names, then (to create) make new ones, then a comment link and their
/// codes, each on a connection of its own; `then` gets the links.
fn share_links(s: Sharing, then: LinksThen) -> Next {
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create));
Next::Ask(
Box::new(ex),
Box::new(move |answers| match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
Ok(Some(links)) => comment_link(s, links, then),
other => then(other.map(|_| None)),
}),
)
}
/// Ask for a comment link beside `links`, then for their codes.
fn comment_link(s: Sharing, mut links: requests::Links, then: LinksThen) -> Next {
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::comment_request(&s.file));
Next::Ask(
Box::new(ex),
Box::new(move |made| {
// A server from before comment links: the file is shared without one.
let made = made.map_err(|e| log::info!("live session: no comment link from {}: {e}", s.server.display()));
links.comment = made.ok().and_then(|a| requests::made_comment(a.replies));
codes(s, links, then)
}),
)
}
/// Ask for the codes of `links`; `then` gets them with their codes.
fn codes(s: Sharing, links: requests::Links, then: LinksThen) -> Next {
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
Next::Ask(
Box::new(ex),
Box::new(move |codes| {
// A server from before join codes: the links are shared without them.
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
}),
)
}
/// Background request `req` (answered as [`Event::Net`] with `job`), for the requests the
/// browser sends: about a server's hosting, and changing who else gets into a file. `None` for
/// the others.
pub(crate) fn background(job: u64, req: NetRequest) -> Option<Next> {
let answer = move |reply: NetReply| Next::Done(vec![Event::Net { job, reply: Box::new(reply) }], None);
Some(match req {
NetRequest::Hosting { server, file, owner, req } => match Endpoint::parse(&server) {
Err(e) => answer(NetReply::Hosting(Err(e))),
Ok((server, _)) => Next::Ask(
Box::new(Exchange::new(server.clone(), None, &file, &owner, req.into_iter().collect())),
Box::new(move |answers| {
let r = answers.and_then(|a| {
let info = a.hosting.ok_or("the server didn't say about its hosting")?;
Ok((info, requests::hosted(&server, a.replies.into_iter().next())))
});
answer(NetReply::Hosting(r))
}),
),
},
NetRequest::Share { server, file, owner, revoke, create } => match Endpoint::parse(&server) {
Err(e) => answer(NetReply::Share(Err(e))),
// As the server's operator when the address carries its key, else as the file's owner.
Ok((server, query)) => {
let key = requests::server_key(None, query);
share_links(Sharing { server, key, file, owner, revoke, create }, Box::new(move |r| answer(NetReply::Share(r))))
}
},
NetRequest::Plugins { .. } | NetRequest::Library { .. } | NetRequest::Upload(_) => return None,
})
}
+349
View File
@@ -0,0 +1,349 @@
//! The browser's requests to a server outside a session ([`crate::exchange`]), run by its
//! [`Driver`] over a scripted socket with a made-up clock: publishing (with and without the
//! server's key, sharing or not, then joining the new file), what a failure leaves, and the
//! background requests the browser can send.
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, FileInfo, HostInfo, LinkInfo, NewLink, Presence, ServerMsg};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, NetReply, NetRequest, Shared, Status};
use crate::driver::{Driver, Socket};
use crate::exchange::{self, Next};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
use crate::join::GuestConfig;
use crate::requests::Publish;
const FILE: &str = "0123456789abcdef";
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
const SERVER: &str = "ws://127.0.0.1:47910/";
/// A socket that records what the driver does.
#[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;
}
}
fn server() -> Endpoint {
Endpoint::parse(SERVER).unwrap().0
}
fn job(key: Option<&str>, share: bool) -> Publish {
let mut doc = Document::new("Poster");
doc.repair();
Publish { server: server(), key: key.map(String::from), name: "Poster".into(), doc: Arc::new(doc), share }
}
fn config() -> GuestConfig {
GuestConfig::new(Invite { server: server(), file: String::new(), token: String::new() }, "Ada".into())
}
/// A background request's driver.
fn run(next: Next, t0: Instant) -> (Driver<Script>, Inbox) {
let inbox = Inbox::new(Arc::new(|| {}));
(Driver::asking(Script::default(), config(), next, inbox.clone(), t0), inbox)
}
/// A publish's driver.
fn publish(job: Publish, t0: Instant) -> (Driver<Script>, Inbox) {
let inbox = Inbox::new(Arc::new(|| {}));
(Driver::publish(Script::default(), config(), job, inbox.clone(), t0), inbox)
}
fn new_link(id: &str, access: Access, token: &str) -> NewLink {
NewLink { link: LinkInfo { id: id.into(), file: FILE.into(), access, label: String::new(), created: 0 }, token: token.into() }
}
fn file() -> FileInfo {
FileInfo { id: FILE.into(), name: "Poster".into(), created: 0, updated: 0, seq: 0, bytes: 0, people: 0 }
}
fn hosting() -> ServerMsg {
ServerMsg::Hosting(Box::new(HostInfo { open: true, operator: "[email protected]".into(), ..HostInfo::default() }))
}
fn admin(r: AdminReply) -> ServerMsg {
ServerMsg::Admin(Box::new(r))
}
fn hosted() -> ServerMsg {
admin(AdminReply::Hosted {
file: file(),
edit: new_link("e0", Access::Edit, TOKEN),
view: new_link("v0", Access::View, "ViewViewViewViewViewVi"),
owner: "owner-token".into(),
expires: Some(9),
})
}
/// The server says `m` to the driver.
fn say(d: &mut Driver<Script>, m: &ServerMsg, at: Instant) {
d.message(&message::encode(m).unwrap(), at);
}
fn last(d: &Driver<Script>) -> &ClientMsg {
d.sock.sent.last().unwrap()
}
fn failure(events: &[Event]) -> Option<&str> {
events.iter().find_map(|e| if let Event::Status(Status::Failed { reason }) = e { Some(reason.as_str()) } else { None })
}
/// The welcome a server sends Ada in the published file, encoded.
fn welcome() -> ServerMsg {
let mut doc = Document::new("Poster");
doc.repair();
let mut server = figcraft_collab::Server::new(doc, TOKEN, figcraft_collab::Limits::default());
let ClientMsg::Hello(h) = figcraft_collab::Client::hello(FILE, TOKEN, "Ada", "") else { panic!("no hello") };
let (_, outs) = server.join(h).unwrap();
outs.into_iter().find_map(|o| if let figcraft_collab::Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap()
}
/// The server makes the links and codes for others, each step checked.
fn make_links(d: &mut Driver<Script>, t0: Instant) {
// As the file's owner, on a connection of its own.
assert_eq!((d.sock.opened.len(), d.sock.closes), (2, 1));
d.opened(t0);
assert!(matches!(last(d), ClientMsg::OpenHosting(o) if o.file == FILE && o.owner == "owner-token"));
say(d, &hosting(), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::Edit, .. })));
say(d, &admin(AdminReply::Link(new_link("e1", Access::Edit, "EditEditEditEditEditEd"))), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::View, .. })));
say(d, &admin(AdminReply::Link(new_link("v1", Access::View, "SeeSeeSeeSeeSeeSeeSeeS"))), t0);
// The comment link, on another.
d.opened(t0);
say(d, &hosting(), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::Comment, .. })));
say(d, &admin(AdminReply::Link(new_link("c1", Access::Comment, "SaySaySaySaySaySaySayS"))), t0);
// Their codes, on another.
d.opened(t0);
say(d, &ServerMsg::Pong, t0);
say(d, &hosting(), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::Code { token, .. }) if token == "EditEditEditEditEditEd"));
say(d, &admin(AdminReply::Code { code: "ABCDEFGHJ".into() }), t0);
say(d, &admin(AdminReply::Code { code: "KLMNPQRST".into() }), t0);
say(d, &admin(AdminReply::Code { code: "VWXYZ0123".into() }), t0);
}
#[test]
fn publishing_without_the_key_uploads_shares_then_joins() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, true), t0);
assert_eq!(d.sock.opened, vec![SERVER], "no key or token in the URL");
assert_eq!(inbox.drain().len(), 1, "connecting");
d.opened(t0);
assert!(matches!(last(&d), ClientMsg::OpenHosting(o) if o.file.is_empty() && o.owner.is_empty()));
// Presence waits for the session.
d.presence(Presence { cursor: Some((NodeId(1), 1.0, 2.0)), ..Presence::default() }, t0);
say(&mut d, &hosting(), t0);
assert!(matches!(last(&d), ClientMsg::Admin(Admin::Publish { name, .. }) if name == "Poster"));
say(&mut d, &hosted(), t0);
make_links(&mut d, t0);
let events = inbox.drain();
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view), comment: Some(comment) }))] = events.as_slice()
else {
panic!("{events:#?}")
};
assert_eq!(h.operator, "[email protected]");
assert_eq!((p.file.as_str(), p.owner.as_deref(), p.expires), (FILE, Some("owner-token"), Some(9)));
assert_eq!((edit.id.as_str(), edit.code.as_deref(), view.code.as_deref()), ("e1", Some("ABCDEFGHJ"), Some("KLMNPQRST")));
assert!(edit.link.contains("token=EditEditEditEditEditEd"));
assert_eq!((comment.id.as_str(), comment.code.as_deref()), ("c1", Some("VWXYZ0123")));
assert!(comment.link.contains("token=SaySaySaySaySaySaySayS"));
// Then the file, with the owner's edit link.
assert_eq!(d.sock.opened.len(), 5);
d.opened(t0);
let ClientMsg::Hello(hello) = last(&d) else { panic!("{:?}", last(&d)) };
assert_eq!((hello.file.as_str(), hello.token.as_str()), (FILE, TOKEN));
say(&mut d, &welcome(), t0);
assert!(matches!(last(&d), ClientMsg::Presence(_)), "the presence that waited goes out with the welcome");
assert!(matches!(inbox.drain().first(), Some(Event::Welcome { .. })));
assert!(!d.done());
}
#[test]
fn publishing_with_the_key_joins_without_sharing() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(Some("k3y"), false), t0);
d.opened(t0);
assert!(matches!(last(&d), ClientMsg::Manage(m) if m.key == "k3y"));
say(&mut d, &admin(AdminReply::Ready), t0);
let published = AdminReply::Published { file: file(), edit: new_link("e0", Access::Edit, TOKEN), view: new_link("v0", Access::View, TOKEN) };
say(&mut d, &admin(published), t0);
let events = inbox.drain();
assert!(matches!(events.as_slice(), [Event::Status(Status::Connecting), Event::Published(p)] if p.owner.is_none()), "{events:#?}");
d.opened(t0);
assert!(matches!(last(&d), ClientMsg::Hello(h) if h.token == TOKEN));
}
#[test]
fn a_failed_publish_says_why_and_joins_nothing() {
let t0 = Instant::now();
let refusals = [
(ServerMsg::Refused { reason: "that message couldn't be read".into() }, "takes files only with its key (it is older than this app)"),
(ServerMsg::Refused { reason: "this server takes files only with its key".into() }, "the server refused: this server takes files"),
(admin(AdminReply::Ready), "something other than an answer"),
];
for (m, why) in refusals {
let (mut d, inbox) = publish(job(None, true), t0);
d.opened(t0);
say(&mut d, &m, t0);
let events = inbox.drain();
assert!(failure(&events).is_some_and(|r| r.starts_with("Couldn't publish to the server: ") && r.contains(why)), "{events:#?}");
assert!(d.done() && d.sock.opened.len() == 1, "nothing more is tried");
}
// The upload's own answer fails, or the key is wrong.
let (mut d, inbox) = publish(job(Some("bad"), false), t0);
d.opened(t0);
say(&mut d, &admin(AdminReply::Failed { reason: "the file is too large".into() }), t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("answered Failed")));
let (mut d, inbox) = publish(job(Some("k"), false), t0);
d.opened(t0);
say(&mut d, &admin(AdminReply::Ready), t0);
say(&mut d, &admin(AdminReply::Failed { reason: "the file is too large".into() }), t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the file is too large")));
// The server goes away, or never answers.
let (mut d, inbox) = publish(job(None, false), t0);
d.opened(t0);
d.closed(1000, "busy", t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the server closed the connection: busy")));
let (mut d, inbox) = publish(job(None, false), t0);
d.closed(1006, "", t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from 127.0.0.1:47910")));
let (mut d, inbox) = publish(job(None, false), t0);
d.opened(t0);
d.text(t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the server sent a text message")));
}
#[test]
fn a_publish_waits_for_the_server_then_gives_up() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, false), t0);
// Until the connection opens, then as long as a large upload may take.
assert_eq!(d.deadline(), Some(t0 + Duration::from_secs(5)));
d.opened(t0);
let wait = crate::limits::NetLimits::default().request_reply_timeout;
assert_eq!(d.deadline(), Some(t0 + wait));
d.tick(t0 + wait - Duration::from_secs(1));
assert!(!d.done());
d.tick(t0 + wait);
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from the server")));
assert!(d.done() && d.deadline().is_none());
// One that never opens.
let (mut d, inbox) = publish(job(None, false), t0);
d.tick(t0 + Duration::from_secs(5));
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from 127.0.0.1:47910")));
}
#[test]
fn leaving_while_publishing_stops_there() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, false), t0);
d.opened(t0);
inbox.drain();
d.leave(t0);
assert!(d.done() && d.sock.closes == 1);
say(&mut d, &hosting(), t0);
assert!(inbox.drain().is_empty(), "nothing more happens");
}
#[test]
fn sharing_that_fails_still_joins_the_file() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, true), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
say(&mut d, &hosted(), t0);
d.opened(t0);
say(&mut d, &ServerMsg::Refused { reason: "wrong owner token".into() }, t0);
let events = inbox.drain();
assert!(matches!(events.last(), Some(Event::Shared(Err(e))) if e.contains("wrong owner token")), "{events:#?}");
assert_eq!(d.sock.opened.len(), 3, "the file is joined anyway");
// A server from before join codes (and comment links) shares the edit and view links without
// codes.
let (mut d, inbox) = publish(job(None, true), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
say(&mut d, &hosted(), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
say(&mut d, &admin(AdminReply::Link(new_link("e1", Access::Edit, TOKEN))), t0);
say(&mut d, &admin(AdminReply::Link(new_link("v1", Access::View, TOKEN))), t0);
for _ in ["the comment link", "the codes"] {
d.opened(t0);
say(&mut d, &hosting(), t0);
d.closed(1000, "that message couldn't be read", t0);
}
let events = inbox.drain();
assert!(
matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none()) && s.comment.is_none())
);
}
/// Run background request `req` to its answer against `answers` (one per message the driver sends).
fn background(req: NetRequest, answers: &[ServerMsg]) -> (NetReply, Vec<ClientMsg>) {
let t0 = Instant::now();
let (mut d, inbox) = run(exchange::background(7, req).unwrap(), t0);
let mut connections = 0;
for m in answers {
// Each exchange on a connection of its own: it opens, then the server answers.
if d.sock.opened.len() > connections {
connections = d.sock.opened.len();
d.opened(t0);
}
say(&mut d, m, t0);
}
assert!(d.done(), "{:?}", d.sock.sent);
let events = inbox.drain();
let [Event::Net { job: 7, reply }] = events.as_slice() else { panic!("{events:#?}") };
((**reply).clone(), d.sock.sent.clone())
}
#[test]
fn background_requests_ask_about_hosting_and_sharing() {
let req = |r| NetRequest::Hosting { server: SERVER.into(), file: FILE.into(), owner: "owner-token".into(), req: r };
let (reply, sent) = background(req(None), &[hosting()]);
assert!(matches!(reply, NetReply::Hosting(Ok((h, None))) if h.open));
assert!(matches!(sent.as_slice(), [ClientMsg::OpenHosting(o)] if o.owner == "owner-token"));
// A link it makes comes back whole.
let create = Admin::CreateLink { file: FILE.into(), access: Access::View, label: String::new() };
let (reply, _) = background(req(Some(create)), &[hosting(), admin(AdminReply::Link(new_link("v2", Access::View, TOKEN)))]);
assert!(
matches!(reply, NetReply::Hosting(Ok((_, Some(AdminReply::Link(l))))) if l.token.starts_with("figcraft://join?") && l.token.contains(TOKEN))
);
// Stopping a share revokes its links, with the key when the address carries it.
let stop =
NetRequest::Share { server: format!("{SERVER}?key=k3y"), file: FILE.into(), owner: String::new(), revoke: vec!["e1".into()], create: false };
let (reply, sent) = background(stop, &[admin(AdminReply::Ready), admin(AdminReply::Done)]);
assert!(matches!(reply, NetReply::Share(Ok(None))));
assert!(matches!(sent.as_slice(), [ClientMsg::Manage(m), ClientMsg::Admin(Admin::Revoke { link, .. })] if m.key == "k3y" && link == "e1"));
// Addresses that aren't one answer at once; what the browser doesn't send stays the engine's.
let bad_hosting = NetRequest::Hosting { server: "nope".into(), file: String::new(), owner: String::new(), req: None };
let bad_share = NetRequest::Share { server: "http://x".into(), file: FILE.into(), owner: String::new(), revoke: Vec::new(), create: true };
for bad in [bad_hosting, bad_share] {
let Some(Next::Done(events, None)) = exchange::background(1, bad) else { panic!("not answered at once") };
assert!(matches!(events.as_slice(), [Event::Net { reply, .. }] if matches!(**reply, NetReply::Hosting(Err(_)) | NetReply::Share(Err(_)))));
}
let plugins = NetRequest::Plugins { server: SERVER.into(), req: figcraft_collab::message::PluginReq::List { query: String::new() } };
assert!(exchange::background(1, plugins).is_none());
}
Loaded 100 of 1105 files, more files were not shown because too many files have changed in this diff. Show more