Compare commits

...
Author SHA1 Message Date
bflatastic 84dc9c4f22 Merge fix/524 into batch 10 (#524's rebased head; batch already has its content) 2026-10-17 15:52:39 +07:00
bflatastic f6bfd6953f Merge #524's rebased head (the batch carries the reviewed resolution) 2026-10-17 15:52:18 +07:00
Andrii VolkovandClaude Opus 5.5 58d891f63d fix(format): one kiwi value per compressed canvas byte
One value per two bytes of the compressed canvas was too tight for a
public design system: a 39.5 MB canvas holding 29.7 million values got
a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75
values per compressed byte; the budget is now one per byte, at least the
old 20 million and at most 128 million. A small file still gets main's
20 million, however far it unpacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
Andrii VolkovandClaude Opus 5.5 62ade9b9ad fix(format): reopen large documents once saved
`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.

That saved file reopens in 13 s with a 5.9 GB peak.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
Andrii VolkovandClaude Opus 5.5 330f80fcf5 fix(format): open large .fig files
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.

The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
  the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
  import's 200 000 and at most 2 000 000. A small or crafted file can't
  expand its instances into millions of layers, however far it unpacks; the
  REST JSON import keeps 200 000.

Memory:
- The mapper no longer passes the document through `json!`, which serialises
  its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
  importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
  objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
  as soon as the next stage is built.

On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
bflatastic da5fe1359b Merge fix/524 into batch 10 2026-10-17 15:26:46 +07:00
bflatastic 546c09b3ba Merge fix/all-f2 into batch 10 2026-10-17 15:26:45 +07:00
bflatastic 5fcf82abfc Merge #524's latest commit (same budget as the fix above)
# Conflicts:
#	crates/format/src/fig/kiwi.rs
#	crates/format/src/fig/tests.rs
2026-10-17 15:19:59 +07:00
bflatastic 58923a22e8 Merge fix/647 2026-10-17 15:11:24 +07:00
bflatastic 426217327b #647: text copied in another app after a design paste pastes as text again 2026-10-17 15:11:23 +07:00
BFlat c1347d28c5 Merge pull request #727 from storytold/community/batch-9
Community batch 9: the .fig import series (11 PRs by @Skyfuller1303)
2026-10-17 15:06:06 +07:00
bflatastic 6020d94d55 Merge fix/647 2026-10-17 14:41:03 +07:00
bflatastic a3e8c86331 Merge fix/620 2026-10-17 14:40:55 +07:00
bflatastic 9f01b21a54 Merge fix/619 2026-10-17 14:40:49 +07:00
bflatastic 5633453f5b Merge fix/524 2026-10-17 14:40:43 +07:00
bflatastic 460985672c Merge fix/656 2026-10-17 14:36:06 +07:00
bflatastic aa90259f1c Merge fix/617 2026-10-17 14:36:06 +07:00
bflatastic b5cba7c885 Merge fix/711 2026-10-17 14:35:20 +07:00
bflatastic 4c1f2b978b Merge fix/697 2026-10-17 14:35:17 +07:00
bflatastic 1328092a7e Merge fix/612 2026-10-17 14:35:15 +07:00
bflatastic 45c68177d0 Merge fix/695 2026-10-17 14:35:14 +07:00
bflatastic 090266ad40 Merge fix/610 2026-10-17 14:35:12 +07:00
bflatastic d07b55104d #647: paste takes the design path only when the HTML holds readable design data; neutral wording in every catalog 2026-10-17 14:34:58 +07:00
bflatastic 3353d2d5d3 #647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64 2026-10-17 14:34:57 +07:00
bflatastic 946006f100 #647: leave progress docs to the maintainer's docs PR 2026-10-17 14:34:56 +07:00
bflatastic f7fb03f25d Merge #632 2026-10-17 14:34:20 +07:00
bflatastic 9415ef6579 Merge #631 2026-10-17 14:34:20 +07:00
bflatastic 2e32a1659c Merge the .fig import stack (#626-#639) fixed for landing 2026-10-17 14:34:19 +07:00
bflatastic edc2845324 Merge #632's reviewed head (its changes are folded in above) 2026-10-17 14:34:17 +07:00
bflatastic 8f9be76fac Merge #631's reviewed head (its changes are folded in above) 2026-10-17 14:34:15 +07:00
bflatastic 92714667f6 Merge main into #647 2026-10-17 14:27:33 +07:00
bflatastic 02c4d7d825 #620: test the summary and report of a file opened from data 2026-10-17 14:27:08 +07:00
bflatastic fd98f572d2 Merge fix/635 2026-10-17 14:26:04 +07:00
bflatastic 4a572572db #635: no negative zero from the flipped tint 2026-10-17 14:25:56 +07:00
bflatastic 01c3f0bca6 #620: the import report and its notes in every catalog; #617's notes added, stale and unshipped ones dropped 2026-10-17 14:21:29 +07:00
bflatastic d40728d0b8 #620: import notes come from the importers' own warn() texts, translated once when the report opens 2026-10-17 14:21:29 +07:00
bflatastic f757044d8c fix/all: rustfmt the merged module list 2026-10-17 14:08:05 +07:00
bflatastic d0539b83e9 #631: follow the decoder rename in #639's connector check
#639 lands first and reads networks with decode_styled; once #631 drops the count it calls
network::decode. Needed whenever #631 is rebased onto a main that has #639.
2026-10-17 14:06:43 +07:00
bflatastic 7cb0b68d73 Merge fix/635 2026-10-17 14:05:09 +07:00
bflatastic 3881cce291 Merge fix/632 2026-10-17 14:05:08 +07:00
bflatastic 3f36adfa90 Merge fix/638 2026-10-17 14:05:05 +07:00
bflatastic add7806197 Merge fix/639 2026-10-17 14:05:02 +07:00
bflatastic de28945484 Merge fix/636 2026-10-17 14:04:59 +07:00
bflatastic 313a38c0a8 Merge fix/630 2026-10-17 14:04:55 +07:00
bflatastic 1e85d54952 #635: cap the .fig import warnings like the REST ones
Image filter warnings name fields of the file, so a crafted schema could add any number of
distinct warnings (each checked against all earlier ones). The mapper keeps at most as many as
the REST importer does.
2026-10-17 14:04:00 +07:00
BFlat 9e28e11954 M3: number variables in the text content picker (#629) (#725)
The Typography section's variable picker also lists number variables for a text layer's content (shown as the number), as the engine already accepted; other text fields keep their own types (#629).
2026-10-17 14:03:59 +07:00
bflatastic 48d74df226 #635: one tint sign for REST file JSON and .fig
Both carry the same image filter value, which the public docs describe as green when lower and
magenta when higher; a positive native tint adds green. Both readers flip it now (the .fig path
did, the REST path didn't). The .fig test message no longer names the product.
2026-10-17 14:03:57 +07:00
bflatastic 601ff4218b Merge #651 into #620: translate the vector style import notes 2026-10-17 14:02:15 +07:00
bflatastic 4771eb45fa Merge #617 into #620: its import notes join the report 2026-10-17 14:02:13 +07:00
bflatastic baa58b21fa #620: file.open with base64 data returns the summary too 2026-10-17 13:59:03 +07:00
bflatastic 24eec5d6bb Merge main into #620 2026-10-17 13:59:02 +07:00
bflatastic 2ac4ce3a73 #635: rebase onto #628: the filter tests add their paint fields themselves
The test schema extended the image scale-mode field that only #634 adds; it now adds the
filter containers to the paint message itself, so it works with and without #634.
2026-10-17 13:58:41 +07:00
Skyfuller1303 d84aa7f1e1 M1: import image adjustments from .fig files
Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once.
2026-10-17 13:58:04 +07:00
bflatastic ca94b5fe6f #632: leave the progress docs to the maintainer 2026-10-17 13:54:27 +07:00
bflatastic 3fadbf4ba9 #632: read the network with the decoder #631 left 2026-10-17 13:54:25 +07:00
bflatastic 6baeb53b20 #619: the Save as messages in every catalog 2026-10-17 13:51:01 +07:00
bflatastic a96222012b #619: leave progress docs to the maintainer's docs PR 2026-10-17 13:51:00 +07:00
Skyfuller1303 6233b90ba7 M1: import editable text on a path from .fig files
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.

The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
2026-10-17 13:49:58 +07:00
bflatastic c605bd5781 #631: drop the styled-segment count nobody reads 2026-10-17 13:49:46 +07:00
bflatastic 3134849b81 Merge main into #619 2026-10-17 13:49:02 +07:00
bflatastic 5fba7e5dba #524: one kiwi value per compressed canvas byte (up to 128M), lower value and document.json ceilings on 32-bit targets 2026-10-17 13:48:56 +07:00
bflatastic ec325c648c Merge main into #631
# Conflicts:
#	crates/format/src/fig/network.rs
2026-10-17 13:46:35 +07:00
bflatastic cca1058b4a Merge main into #524 2026-10-17 13:46:29 +07:00
bflatastic 99dc199bb8 #656: borrow embedded image bytes and copy only the ones a paint uses 2026-10-17 13:46:22 +07:00
bflatastic 22bdad22fe #656: leave progress docs to the maintainer's docs PR 2026-10-17 13:46:21 +07:00
Skyfuller1303 d9e79d604b M1: distinguish unresolved vector segment style metadata in .fig import
The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model.
2026-10-17 13:45:55 +07:00
bflatastic b31ef2273a Merge main into #656 2026-10-17 13:45:26 +07:00
bflatastic 7fe0770b9d #617: point joins equal to the layer's no longer count as point styles (SVG keeps a plain stroke) 2026-10-17 13:45:16 +07:00
bflatastic 6023d3547a #617: leave progress docs to the maintainer's docs PR 2026-10-17 13:45:15 +07:00
bflatastic 15533338e8 Merge main into #638 2026-10-17 13:44:45 +07:00
bflatastic ee3e3f0bd4 #637: skip ink only counts under an underline
The public docs don't give a default for skip ink; they say the value is unset when the text
isn't underlined, and the exporter always wrote it on. So the export keeps writing on for text
without an underline and the real value under one (that's what the canvas draws), and the import
leaves a value on text without an underline out of the decoration look, so plain text keeps the
default look and round-trips unchanged. The native default stays off, so existing documents
draw as before.
2026-10-17 13:44:34 +07:00
bflatastic 9de5b333ac Merge main into #617 2026-10-17 13:41:04 +07:00
bflatastic 9b4e45aa0b #711: move the vector.offset tests into their own module 2026-10-17 13:40:55 +07:00
bflatastic 0dac325868 Merge main into #637 2026-10-17 13:40:41 +07:00
bflatastic 0ef8966844 #639: drop the ignored test that needs a fixture outside the repo 2026-10-17 13:40:30 +07:00
bflatastic e4f2c7fc4c Merge main into #711 2026-10-17 13:38:42 +07:00
bflatastic f44d7ed598 Merge main into #639 2026-10-17 13:36:31 +07:00
bflatastic 102f553f2c Merge main into #697 2026-10-17 13:35:58 +07:00
BFlat 69806c9edd Plugins: refuse file and network access through path params and collab (#723)
Plugins can no longer reach the disk or network through path parameters (variable.export/codeConnect.export/component.playground writes, codeConnect.import/variable.import reads) or through collab.* and window.*; found and fixed by @lucasmartins-ai, split out of #696.
2026-10-17 13:35:00 +07:00
bflatastic c42207fdee Merge main into #636 2026-10-17 13:33:43 +07:00
bflatastic fbe953e9b0 Merge main into #612 2026-10-17 13:33:11 +07:00
bflatastic b6820d6710 Merge main into #630 2026-10-17 13:31:18 +07:00
bflatastic ab6cf4fd58 Merge main into #628 2026-10-17 13:27:46 +07:00
Andrii VolkovandClaude Opus 5.5 7dcd7fd05b fix(format): one kiwi value per compressed canvas byte
One value per two bytes of the compressed canvas was too tight for a
public design system: a 39.5 MB canvas holding 29.7 million values got
a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75
values per compressed byte; the budget is now one per byte, at least the
old 20 million and at most 128 million. A small file still gets main's
20 million, however far it unpacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 09:27:42 +03:00
bflatastic 51e02a6b1a #695: leave progress docs to the maintainer's docs PR 2026-10-17 13:27:28 +07:00
bflatastic b4d58a362e #627: leave the progress docs to the maintainer 2026-10-17 13:27:13 +07:00
bflatastic 6e15e30790 #627: remove the dead network::decode shim 2026-10-17 13:27:12 +07:00
bflatastic b9d587e80e Merge main into #695 2026-10-17 13:27:11 +07:00
bflatastic fa4821bcea #627: settle bound paints once per open, not also in the native loader
DocState::new already settles every opened document (native files, branches and live joins
included); the .fig/REST path keeps its settlement in link_instances for file.import into an
open document.
2026-10-17 13:27:11 +07:00
bflatastic de06c1ed5f Merge main into #610 2026-10-17 13:25:09 +07:00
bflatastic b0e9e87129 Merge main into #627 2026-10-17 13:21:48 +07:00
bflatastic 52df73c44c Merge main into #626 2026-10-17 13:10:59 +07:00
BFlat 40c744da10 M1: convert frames to sections and sections to frames (#719) (#721)
Frames on the page or in a section convert to sections (object.convertToSection) and sections back to frames (object.convertToFrame), in place, one undo step each, from the canvas context menu; nested frames, instances, main components and sections holding sections are refused.
2026-10-17 13:03:49 +07:00
NetterSilverandClaude Haiku 5.5 9e2596e975 Skip layers with children in vector.offset
Found while testing crates/engine/src/cmd/structure.rs:575

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:09:53 +00:00
RealtaNuaandClaude Opus 5.5 d27f45e063 fix(format): write a list's start on every line; report it in get_node (#686)
The .fig export now writes a numbered list's start on each of its lines,
so a reader that takes it per line or per list counts the same. Bullets
no longer keep a start. The field is named start_offset (it holds the
offset: 1 shows "2."). get_node reports listStartOffsets. The parity doc
lists what's still not read from .fig lists.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:58:20 +07:00
NetterSilverandClaude Haiku 5.5 9aeb9fb752 Cap stroke width in the outliner so huge weights don't panic
Found while testing crates/engine/src/cmd/props.rs:361 (strokeWeight is only clamped with .max(0.0)); the panic is raised when the renderer draws the stroke (render crate / tiny-skia stroker)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:58:00 +02:00
RealtaNuaandClaude Opus 5.5 60fa813eb2 fix(format): numbered lists from .fig files keep their start number (#686)
Figma stores where a numbered list starts counting on each text line
(listStartOffset; a list shown as "2." has offset 1). The import dropped
it, so every list counted from 1.

A paragraph's list settings now carry a start number (written only when
set, so older files open and save unchanged). Numbering counts on from
it where a list starts; items that go on counting ignore it. The .fig
import reads it, the .fig export writes it on a list's first line, and
plain paragraphs drop it. Toggling a list off ignores differing starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:27:34 +07:00
Dylan M. Taylor d1d8c3f652 ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action (#640)
* ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action

* ui: widen About window so contributor table fits with Inter font
2026-10-16 10:08:58 -06:00
loyet 11455e0fe9 M1: .fig import reads images carried in the message (image.dataBlob)
Copies written by plugins and tools carry their image bytes in the
message: a paint's image.dataBlob indexes the message's blobs. A bare
canvas (what the clipboard holds, see #647) has no images/, so these
fills came in as grey placeholders. The importer now falls back to the
embedded bytes, keyed by image hash, after the archive's images/.

Blobs over the image size cap and indexes past the blobs are skipped and
stay placeholders. Tests: a bare canvas carrying its image in a blob, and
a dataBlob past the blobs.
2026-10-16 10:44:29 +02:00
Andrii VolkovandClaude Opus 5.5 0efddc3894 fix(format): reopen large documents once saved
`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.

That saved file reopens in 13 s with a 5.9 GB peak.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 10:46:08 +03:00
Andrii VolkovandClaude Opus 5.5 12d43b1f99 fix(format): open large .fig files
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.

The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
  the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
  import's 200 000 and at most 2 000 000. A small or crafted file can't
  expand its instances into millions of layers, however far it unpacks; the
  REST JSON import keeps 200 000.

Memory:
- The mapper no longer passes the document through `json!`, which serialises
  its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
  importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
  objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
  as soon as the next stage is built.

On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 10:46:08 +03:00
Skyfuller1303 41d465a678 M1: reword the vector segment style import notes 2026-10-16 09:40:06 +02:00
Skyfuller1303 d8e64a73bf M1: reword the vector segment style import notes 2026-10-16 09:40:04 +02:00
Skyfuller1303 771f7e869f M1: reword the unresolved point style import note 2026-10-16 09:40:04 +02:00
Skyfuller1303 657a8f4df6 M1: translate the vector style import notes 2026-10-16 09:16:10 +02:00
JimKnoxxandClaude Opus 5.5 ab10e37d43 M1: paste layers copied in Figma
Figma puts copied layers in the clipboard's HTML as a base64 .fig canvas
between (figma) markers. edit.pasteFromFigma reads it with the .fig import,
turns the layers into the session clipboard and pastes them as edit.paste
does (selected frame, in place or view centre), with the import's warnings.
The desktop app reads the clipboard's HTML (arboard) and Paste prefers a
Figma copy; the browser's paste event carries plain text only.

Tested on copies written by our own .fig export; not yet on real copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:20:49 +02:00
skylar taylor-barrick 23bdebd973 Merge pull request #176 from dylanmtaylor/use-craft-common-actions
ci: delegate release, freebsd, windows-arm64, packaging-lint to craft-common-actions and add nix check
2026-10-15 21:34:16 -06:00
skylartaylor d9584410af ci: retain FigCraft package and metadata checks 2026-10-16 05:24:23 +02:00
Skyfuller1303 c4536f412b M1: import labels of straight connectors from .fig files
Straight connectors saved with a manual routing snapshot (connector and derived-frame version 1) now come in as an editable vector plus their label text layers, instead of an empty frame. Label placement composes the saved background and text transforms, and label paints and text styles use the existing import path. Connectors of other versions or line styles keep the existing fallback with their compatibility notes. At the layer depth or node limit the saved vector is kept without extra label layers, and skipped labels are reported once. Live endpoint bindings and routing are not retained.
2026-10-16 05:08:37 +02:00
Skyfuller1303 7fd44641f2 M1: keep paragraph options from .fig files
Text layers, text styles and instance overrides in .fig files can carry list spacing, hanging list, hanging punctuation and leading trim. The importer now reads them from the highest layer with a valid value, so an invalid instance override row falls through to the valid value below it, while explicit zero, false and NONE values stay resets. Both text layers and text styles share one mapping. The native text data stores these options once per text node, so differing values on separate runs are not represented, and the experimental .fig exporter keeps its existing note that these settings are left out.
2026-10-16 05:03:38 +02:00
Skyfuller1303 3723fcffbd M1: keep text decoration skip ink from .fig files
Text layers, styled runs and text styles in .fig files carry a skip-ink boolean for their decoration. The importer now reads it into the native decoration look: an omitted run value inherits the layer's, a local value overrides a linked shared text style without changing the style itself, and instance overrides keep an explicit false over a true main. A malformed value counts as unset. The exporter writes the actual value of the layer and of each styled run instead of a constant true, and a decoration look that differs from the default only in skip ink no longer triggers the unsupported-decoration note, so default native text exports its real false value.
2026-10-16 05:03:34 +02:00
Skyfuller1303 120b6830ff M1: import editable text on a path from .fig files
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.

The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
2026-10-16 05:03:29 +02:00
Skyfuller1303 a52e2bb6b3 M1: distinguish unresolved vector segment style metadata in .fig import
The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model.
2026-10-16 05:02:27 +02:00
Skyfuller1303 8408cc72b8 M1: import component slot content from .fig files
Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main.

Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed.
2026-10-16 04:59:11 +02:00
Skyfuller1303 0b40964202 M1: keep grid track sizes in .fig import
Grid columns and rows keep fixed pixel sizes, Hug sizing and fractional Fill weights when the minimum and maximum sizing definitions agree. Track GUIDs join the sizing records to the ordered tracks, so reordered records do not change cell placement. Unsupported min/max pairs, invalid sizes and duplicate records fall back with a reported note, and imports are bounded to 1,000 tracks per axis. Public REST snapshots ignore the private track metadata.
2026-10-16 04:58:11 +02:00
Dylan M. Taylor 3489e01a22 ci: delegate release, freebsd, windows-arm64, packaging-lint to craft-common-actions and add nix check 2026-10-15 21:39:02 -04:00
Skyfuller1303 d2606b3f51 M1: let local text values and instance overrides win over a shared text style in .fig import
A text layer that links a shared text style keeps its own, possibly stale, copy
of the typography. The style's typography now beats that copy, a case,
decoration, paragraph spacing or paragraph indent set on the layer itself beats
the style (so "original", "none" and zero clear the style's value), and an
override row of an instance beats both. An explicit "no style" on a text layer
unlinks it even when the older style field still names one.

Letter spacing given as a percentage of the font size stays a percentage. A text
property value on an instance no longer inherits the character styles written
for the main's text, which count other characters.
2026-10-16 03:32:39 +02:00
Skyfuller1303 4b29d8c34d M1: apply instance overrides by key and order, settle bound paints, keep stroke outline styles
Override rows of an instance can name a layer of its main by the key the layer
had before the main was edited. The key now resolves to the layer's current id
when it is unambiguous and the current id does not exist already. Rows of an
enclosing instance win over those of the instances nested in it, whatever order
the wire lists them in, and an explicit "no style" on an override detaches the
style instead of falling back to the older field.

Paints bound to variables take the colours of the active modes when a document
loads, before the first edit, without touching other pending changes.

A stroke-only vector that imports as the outline of its stroke now moves the
stroke's shared style and colour variables to the fill it becomes, and vectors
whose single segments carry their own stroke style say so in a warning.
2026-10-16 03:32:38 +02:00
Skyfuller1303 d331db00aa M1: keep explicit instance identity and cached library components in .fig import
A .fig import now links the layers of an instance to the layers of the
component they came from by the ids the file stores, instead of leaving it to
the engine to guess from names and places. Renamed slots and layers, siblings
that share a name and swapped mains keep their source link and the override
they carry.

Components that live only on the file's hidden internal pages (cached copies of
library components) are kept in the hidden library store when a visible layer
uses them, so their instances stay instances. The read-only guard for library
copies now compares the copies themselves rather than the dirty set, which an
import leaves full until the first edit.

Rebasing an instance (after detaching an outer instance or restoring a main)
follows the source chain inside the right enclosing instance, so contextual
links survive it. The last change of a GUID wins and a removed change deletes
it. Ordinary file JSON never reads the private provenance.

Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target.
2026-10-16 03:32:36 +02:00
BFlat f40c600e67 Docs: roadmap rows for the community fixes from #478 up (#591, #598, #602) and #589 (#605) 2026-10-16 07:55:52 +07:00
BFlat 9c2d61f6ae Merge pull request #601 from Skyfuller1303/feat/drop-to-open
M7: drop files anywhere to open them
2026-10-16 07:41:02 +07:00
skylar taylor-barrick e4fe308cdf Merge pull request #608 from Silvertree2010/fix/h1871-file-close-with-a-negative-index
Reject negative or fractional index in file.close
2026-10-15 18:28:25 -06:00
Skyfuller1303 01daf92fc3 M1: show an import report after opening a file
Opening or importing a .fig or REST JSON file, or an SVG that has notes,
from the app (File > Open or Import, a recent file, a drop, a paste, the
system's open event) now shows an Import report window with the
document's name, its page, layer and image counts and the compatibility
notes the importer collected, instead of leaving them only in the command
result. Commands sent over the control channel or MCP never open it.

file.open and file.import return the counts as `summary`; ui.inspect
reports the open report as `importReport`. The fixed notes the importers
emit are translated (German and Spanish) and shown with a capital first
letter; other notes, such as SVG's, show as written. The report goes
away with its document. Escape or Close dismisses it.
2026-10-16 02:18:50 +02:00
Skyfuller1303 d4394e87cc M1: save a copy as .fig
Save as now offers .figcraft and .fig as separate file types. A .fig
destination, chosen in the dialog or passed to file.saveAs and file.save,
writes the existing experimental .fig export of the document that is shown
and reports how much was left out. The file's own path, unsaved changes,
versions, branches and undo history are not touched, and a shown earlier
version stays shown while the copy is written or a Save as choice is
pending.

Plain Save no longer writes over a file whose saved path ends in .fig;
earlier builds could write native data under that name. It asks for a
destination instead.
2026-10-16 02:03:46 +02:00
vxMacDev c56a65f58b fix(format): keep vertical trim on .fig import (#466)
Figma's schema has leadingTrim on NodeChange (NONE, CAP_HEIGHT), and
the REST importer already reads style.leadingTrim, but the .fig mapping
never copied it, so text set to "Cap height to baseline" imported
untrimmed in its trimmed box. text_style now copies it with the other
text fields, from the layer or its text style.
2026-10-15 18:45:06 -05:00
Skyfuller1303 924310c124 M1: keep per-point stroke joins on .fig import
Vector points carry their own stroke join in a .fig file. Import now keeps each point's join as the point's own override instead of dropping it for the layer's join.

Joins equal to the layer join no longer force styled stroke runs, and an absent layer join counts as miter. Unresolved point styles, unknown joins, and joins that dashed or variable-width strokes cannot draw each produce a warning.
2026-10-16 01:34:52 +02:00
Skyfuller1303 0404448f74 M1: keep imported file bytes out of the command journal
Pasting or dropping a file runs file.import with the whole file as Base64
data, and every successful command was journaled with its parameters, so
each imported file stayed in memory a second time for the session. Byte
imports, including ones wrapped in command.batch, are no longer journaled.
Imports by path and all other commands are unchanged.
2026-10-16 01:34:29 +02:00
BFlat 4ee413acde Merge pull request #606 from storytold/community/batch-8
Community batch 8: drop files anywhere, duplicate fill tests
2026-10-16 06:18:07 +07:00
Skyfuller1303 1f6010e07c M7: open dropped files that have no file on disk
A home-screen drop that carries only bytes queued file.open with data and name, which file.open rejected because it requires a path. file.open now also takes base64 data with a name and opens it as a new document without a save path.
2026-10-16 00:22:14 +02:00
NetterSilverandClaude Haiku 5.5 90d7521840 Reject negative or fractional index in file.close
Found while testing crates/engine/src/cmd/file.rs:1283

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:19:49 +02:00
bflatastic 665a578957 Merge branch 'fix-601' into batch/t8 2026-10-16 05:04:14 +07:00
bflatastic 44ac32db0c Merge branch 'pr-600' into batch/t8 2026-10-16 05:04:13 +07:00
bflatastic f948e08fbc Merge remote-tracking branch 'origin/main' into fix-601
# Conflicts:
#	ROADMAP.md
2026-10-16 05:04:09 +07:00
bflatastic 65f5771a6e Home: describe the drop target without naming other software 2026-10-16 05:04:00 +07:00
BFlat 33d59932f9 Merge pull request #602 from storytold/community/batch-7
Community batch 7: Czech plurals and bounded canvas overscan
2026-10-16 04:51:25 +07:00
bflatastic 049f97a4c3 Merge branch 'pr-597' into batch/t7
# Conflicts:
#	ROADMAP.md
2026-10-16 03:46:48 +07:00
bflatastic 62a291fcb8 Merge branch 'pr-596' into batch/t7 2026-10-16 03:46:43 +07:00
BFlat 7459cc7f86 Merge pull request #598 from storytold/community/batch-6
Community batch 6: 8 reviewed PRs
2026-10-16 03:45:01 +07:00
Barzabel d27ae11d15 M1: a duplicate's fill stays its own (#586)
Regression test for #586: with the colour picker open on a red rectangle or
pen path, duplicating it (edit.duplicate, or copy and paste) and recolouring
the copy leaves the original red. The cause was the picker staying bound to
the original after the selection moved to the copy, fixed by #523; without
that fix this test fails. The duplicated paint is already independent data.
2026-10-15 23:06:31 +03:00
Skyfuller1303 b640069961 M7: drop files anywhere to open them
- Files dragged over the window show a "Drop to open" (home screen) or
  "Drop to import" (canvas) overlay.
- On the home screen each dropped file the app opens becomes a document of its
  own; any other file is turned away with a toast.
- On the canvas dropped files import as before, at the drop point.
- A small card ("Opening as a new file", "Adding to {document}") is on screen
  before a large file blocks the window; the files then open a frame later.
- A canvas import stays with the document and page that received the drop,
  keeps its placement, restores the page and selection chosen meanwhile, and is
  cancelled with a message if that document or page is gone.
- ui.inspect reports pendingOpens; every new string, including the cancel
  messages, goes through tr() with en, de and es entries.
2026-10-15 21:48:25 +02:00
bflatastic 9f935718fe Merge branch 'rb-578' into batch/t6
# Conflicts:
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:37:43 +07:00
bflatastic 1c863aa9f5 Merge branch 'rb-568' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 891f0acb3c Merge branch 'rb-560' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 55c05338a1 Merge branch 'rb-554' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 4e3fd82bd1 Merge branch 'rb-549' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 41b08a2393 Merge branch 'rb-528' into batch/t6 2026-10-16 02:36:54 +07:00
bflatastic 730e377418 Merge branch 'rb-487' into batch/t6 2026-10-16 02:36:53 +07:00
bflatastic 175fdfa428 Merge remote-tracking branch 'origin/main' into rb-478
# Conflicts:
#	crates/ui-egui/src/idle_tests.rs
#	crates/ui-egui/src/lib.rs
2026-10-16 02:36:34 +07:00
bflatastic d60a27b675 Merge main into #554: one id map for the copy, and #562's flows, prototype and guides follow it
page.duplicate copies every layer with duplicate_all_into (navigation between
copied frames points at the copies) and builds the old-to-new list for flows
from that copy, instead of copying each layer separately.
2026-10-16 02:34:54 +07:00
bflatastic fd6a7e0ba3 Merge main into #549: keep #539's whole URL and add the empty-URL arm 2026-10-16 02:33:05 +07:00
bflatastic db8bf6ba33 Merge main into #560; normalise the replacement where findReplace reads it
Main's case-insensitive replace loop (from an earlier PR) took the place of the
loop this change edited, so the replacement text is normalised once, where
the parameter is read.
2026-10-16 02:31:34 +07:00
LosminekandClaude Opus 5.5 4d9cde8fee i18n: Czech one/few/other plurals
Czech counts take three integer forms: one (1), few (2-4) and other
(0 and 5+). Since #257 catalogs can supply `few`, so `plural()` gets a
Czech rule and the 38 plural entries in cs.toml now read naturally
("1 vrstva", "3 vrstvy", "5 vrstev") instead of the count-neutral
"Vrstvy: {n}" that #469 used for everything but 1.

Test czech_integer_plurals covers the rule (1; 2-4; 0, 5, 11, 12, 21,
22, 100, 1 000 000), a real key through counted(), and that every Czech
plural entry has one and few. Docs no longer say Czech has only one/other.

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

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

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

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

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

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

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

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

Refs #256

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

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

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

Refs #256

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

Refs #256

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

Refs #256

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

Refs #256

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behaviour change: comments and docs only.

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

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

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

No behaviour change: comments only.

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

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

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

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

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

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

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

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

Refs #256

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

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

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

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

Refs #256

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

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

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

Refs #256

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

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

Refs #256

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
  installed link, the invite-link registration) plus one `Live` per session. One place resolves
  "the session of this document": `Session::live()` (the active document's; status off when it
  has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
  spotlight and presence all go through the session of the document they are about, so edits and
  presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
  `(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
  link keep them in a shared table with one mailbox per session
  (`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
  `collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
  one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
  peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
  touching other sessions; until then (or if it fails) it shows on the tab it was started from.
  Starting a session for a document replaces only that document's own session. Closing a document
  ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
  banner, follow frame and read-only reason read the session of the tab in front, per window. What
  belongs to one session in the window (cursors shown, your chat box, a report being written) is
  dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
  `sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
  `docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
2026-10-12 17:54:18 +07:00
bflatastic 0e6dc6871b M7: .fig export that opens and draws in other software
- The document and its pages now say they are visible and opaque, and a page carries its background opacity and enabled flag. Left out, a reader took them as hidden and drew nothing: every frame was blank and the page background transparent.
- The schema written into the file uses the format's own names, kinds (struct or message), field ids and enum values for everything the export writes. The schema text parser takes `NAME=n` and `type field=n`.
- Rectangles are written as rounded rectangles with their four radii. Frames, components, instances and sections carry their fill outline; plain strokes carry their stroke outline (a ring around boxes and whole ellipses, the widened path of other centred strokes).
- Text carries its layout settings and one line entry per paragraph. Font styles are written under their standard names ("SemiBold" becomes "Semi Bold", "Regular Italic" becomes "Italic"); the import reads both spellings as the same style.
- A line height that is a share of the font size is written as a raw multiplier: percent units count from the font's own line height, so 140 % was laid out taller than in FigCraft.
- Masks keep their type (alpha, outline, luminance), written and read back.
- The import reads per-side stroke weights whose zero sides are left out (a bottom-only border no longer comes back on all four sides).
- `meta.json` gives the page colour and the thumbnail size; archive members are in the usual order. Texture effects are written under the format's grain type; WebP export settings are written as PNG with a warning (the format has none).
- Docs: `docs/file-format-parity.md`, `docs/gaps.md` (gap 1), `ROADMAP.md`, `docs/target-app-parity.md` (wording only; no number moves).
2026-10-12 17:54:18 +07:00
bflatastic 2cf4510cbd M1: a double-click that enters a text selects all of it
A double-click in the text already being edited still selects the word.
2026-10-12 17:54:18 +07:00
BFlat 47fff74655 M1: double-clicks into text in groups, and a drag threshold (#160)
A move or a resize starts only once the pointer has travelled 3 px on screen, and a double-click drops the gesture its second press began, so double-clicking text inside a group reliably selects it and then edits it instead of sometimes dragging or resizing it.
2026-10-12 16:52:37 +07:00
475 changed files with 61560 additions and 4142 deletions

No files matched your search

+2
View File
@@ -29,6 +29,7 @@ concurrency:
jobs:
test:
timeout-minutes: 35
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
@@ -69,6 +70,7 @@ jobs:
run: cargo run --locked -q -p xtask -- assets
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
+10 -36
View File
@@ -1,8 +1,7 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners).
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
# uploaded and no secrets are used.
# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script
# the release job runs, and checks the tarball, so a broken FreeBSD package fails here
# 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
@@ -11,6 +10,8 @@ name: FreeBSD
on:
schedule:
- cron: "17 2 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
@@ -27,35 +28,8 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# The release package, as release.yml builds it. Free the debug build's disk first.
rm -rf target/debug
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
grep -E "$f" "$HOME/freebsd-contents.txt"
done
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
+4 -54
View File
@@ -19,57 +19,7 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Server install script (Caddyfile handling)
run: bash packaging/server/install-test.sh
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
./packaging/windows/check-icons.ps1
- name: FreeBSD package layout (dry run, no build)
run: bash packaging/freebsd/package.sh --dry-run
- name: No template leftovers from other apps in package metadata
run: |
if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then
echo "::error::package metadata mentions another product (see above)"
exit 1
fi
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
+24 -532
View File
@@ -1,11 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "FigCraft v<version>". See docs/releasing.md.
#
# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the
# `release` environment (restricted to the `release` branch), which holds the signing secrets; the
# rest build on any branch, so `gh workflow run release.yml --ref <branch>` is a real dry run.
# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed
# build. Playbook: storytold/craftrules release/playbook.md.
name: Release
on:
@@ -19,534 +11,34 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
FIGCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
# embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build
# embeds Inter only, see crates/text/build.rs). An optional build input for everyone else.
# crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump
# deliberately; every build job checks out this commit.
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
- name: Verify signatures and notarization
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/verify.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install build and packaging tools
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 \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/figcraft$|metainfo|applications'
ldd target/release/figcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
server:
name: Server Linux ${{ matrix.arch }}
needs: version
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
# with the binary, licences, docs/server.md and the deployment examples. Same runners as the
# app, so the same glibc floor; no craft-fonts (the server draws no text).
# shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli,
# when people host there.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04 }
- { arch: aarch64, runner: ubuntu-22.04-arm }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-server-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Build and package (tar.gz)
run: packaging/server/package.sh
- name: Check the archive
run: |
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
done
ldd target/release/figcraft-server
- uses: actions/upload-artifact@v4
with:
name: release-server-linux-${{ matrix.arch }}
path: dist/release/*.tar.gz
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small
# for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner.
runs-on: github-ubuntu-16core
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
grep -E "$f" "$HOME/freebsd-contents.txt"
done
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
# The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have
# no system fonts to fall back on, and the rest would push the wasm towards
# packaging/web/package.sh's 24 MiB gate.
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU.
runs-on: ubuntu-24.04
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- uses: actions/upload-artifact@v4
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="FigCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo run -p xtask -- version set X.Y.Z) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: figcraft
app-title: FigCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
has-server: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+12 -77
View File
@@ -4,15 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
# change the Windows packaging itself.
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "41 4 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * *"
workflow_dispatch:
concurrency:
@@ -22,77 +24,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: figcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: figcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'FigCraft'
# figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed.
foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) {
if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue }
throw "$exe not installed in $dir"
}
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed binary runs natively on ARM64 (the CLI when present, else the app's --version).
$cli = Join-Path $dir 'figcraft-cli.exe'
if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' }
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" }
Write-Output "ok $(Split-Path -Leaf $cli) --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: figcraft
app-dir: FigCraft
Generated
+42 -23
View File
@@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"arboard",
"eframe",
@@ -1418,6 +1418,7 @@ dependencies = [
"figcraft-format",
"figcraft-text",
"figcraft-ui-egui",
"fmv-macos-events",
"image",
"log",
"rfd",
@@ -1430,7 +1431,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1444,7 +1445,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1459,7 +1460,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1482,7 +1483,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"kurbo",
"serde",
@@ -1491,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1503,7 +1504,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1530,9 +1531,10 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
"flate2",
"image",
"ruzstd",
@@ -1544,7 +1546,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"kurbo",
"serde",
@@ -1552,7 +1554,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1560,7 +1562,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1576,7 +1578,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1585,7 +1587,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1602,7 +1604,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1615,7 +1617,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1633,7 +1635,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1657,7 +1659,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1666,17 +1668,20 @@ dependencies = [
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"flate2",
"thiserror 2.0.21",
"usvg",
]
[[package]]
name = "figcraft-text"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"log",
"png",
"skrifa 0.47.0",
"unicode-bidi",
"unicode-segmentation",
@@ -1684,7 +1689,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1692,6 +1697,7 @@ dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-engine",
"figcraft-format",
"figcraft-geom",
"figcraft-layout",
"figcraft-pathops",
@@ -1703,17 +1709,19 @@ dependencies = [
"serde_json",
"toml",
"wat",
"web-time",
]
[[package]]
name = "figcraft-web"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"eframe",
"egui",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-ui-egui",
"js-sys",
"log",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -1746,6 +1754,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -3922,9 +3941,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
[[package]]
name = "ryu"
@@ -5803,7 +5822,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"figcraft-engine",
"serde_json",
+2 -2
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -59,7 +59,7 @@ linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
+34 -5
View File
@@ -70,7 +70,7 @@
</td>
<td width="25%" valign="top">
<h3>🔌 Offline and private</h3>
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no telemetry, and nothing leaves your computer unless you share a file (<a href="#privacy-and-online-sharing">what sharing sends where</a>). SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
</td>
<td width="25%" valign="top">
<h3>🤖 Agent-ready</h3>
@@ -191,7 +191,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, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
@@ -227,10 +227,10 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
@@ -251,6 +251,10 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
git clone https://github.com/storytold/craft-fonts ../craft-fonts
```
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
@@ -260,6 +264,31 @@ Installers for macOS (universal, signed and notarized), Windows (x64, x86 and ar
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
FigCraft works offline: it has no account and no telemetry, and it contacts no server until you
share or join a file. Live sessions are a **preview**. Before you share anything that matters,
know what sharing does:
- **Share ▸ Online uploads the file to a server.** By default that is the public server at
`collab.craftapps.dev`; you can set another one, or your own, in Preferences (and a build can
ship without it: [`docs/releasing.md`](docs/releasing.md#the-default-server)). The file stays on
the server after you stop sharing, until you remove it (Share ▸ Advanced ▸ Remove from server…)
or nobody has opened it for 30 days, when a public server deletes it.
- **It is not end-to-end encrypted.** The connection to the server is encrypted (TLS), but the
server stores the file unencrypted and whoever runs the server can read it. Don't share
confidential work through a server you don't trust; run your own
([`docs/server.md`](docs/server.md)) or share on the local network instead.
- **The code or link is the access.** There are no accounts: anyone who has an edit code or link
can open and change the file, anyone with a view code or link can open it. Stop sharing revokes
them and disconnects everyone else.
- **Share ▸ Local network involves no server**, but it is not encrypted either: your app hosts
the session and others connect to it directly, so use it on networks you trust.
- **No guarantees.** The public server is offered as is, without backups or uptime promises, and
files on it can be removed. Keep your own saved copy of anything you share.
The default server's terms are in [`docs/online-sharing-terms.md`](docs/online-sharing-terms.md).
## The Crafting Apps
FigCraft is one of the **Crafting Apps**: free, open-source creative tools from the
+116 -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-12 · **Change:** minor (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -13,10 +13,10 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (207 / 207) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| 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 |
@@ -26,8 +26,8 @@ start with [`docs/gaps.md`](docs/gaps.md).
| 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) |
| 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 experimental `.fig` export opens elsewhere (5–15) |
| Essentials user | ~57% | ~60–110 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), onboarding and starter templates (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@@ -58,7 +58,7 @@ 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, checked only through our own importer) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
@@ -100,18 +100,18 @@ native speakers' reviews of the six language drafts need people.
|---|---|---|---|
| English | en | 100% | full |
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 87 entries) | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| French | fr | 99% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 99% | draft (needs native review) |
| French | fr | 100% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
| Indonesian | id | 0% | none |
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 99% | draft (needs native review) |
| Korean | ko | 99% | draft (needs native review) |
| Japanese | ja | 100% | draft (needs native review) |
| German | de | 100% | draft (needs native review) |
| Korean | ko | 100% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
@@ -123,7 +123,7 @@ IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-pa
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 experimental `.fig` export (landed 2026-10-12) on real files: 5–15 h.
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages: 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).
@@ -131,12 +131,70 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged.
- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged.
- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged.
- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged.
- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged.
- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from
their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as
derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings,
the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the
import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width;
number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged.
text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged.
- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page,
dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a
Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting
started card that can be closed and comes back from the Help menu
- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app
quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online**
lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from
the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a
crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
upgrade only to tell which files are still there. The six draft catalogs translate every string again
- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge
preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and
object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are
kept, and dark pages (white text on them) for files made in the dark theme
- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or
**Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that
carries the host's address); others type it under Join with a code (the popover, the home screen), where a link
works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`,
`collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links)
- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and
compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing
goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before
in the design-system example). One font family; real files still unchecked (gap 3). No number moves
- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at
once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover,
people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`),
and closing a file ends its session only (before, one session followed every tab and starting another replaced it)
- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key
(Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are
kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address,
idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and
`packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups
([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature).
- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other
software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The
export now says that the document and its pages are visible and opaque, numbers its schema as the format does,
and writes outlines, text settings, standard font style names and line height units; effects such as glass and
noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers
unchanged.
- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig
(experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers,
geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and
@@ -257,6 +315,50 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 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 | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged |
| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged |
| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged |
| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged |
| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged |
| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged |
| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged |
| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli <args>` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged |
| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged |
| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged |
| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged |
| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged |
| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged |
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. |
| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. |
| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. |
| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged |
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged |
| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged |
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged |
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding |
+14
View File
@@ -0,0 +1,14 @@
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
fn main() {
main_thread_stack();
}
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
/// to 256 deep in an export, and that recursion needs the same room everywhere.
fn main_thread_stack() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
}
+46 -3
View File
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
match name {
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
Some(n) => doc
.pages
.iter()
@@ -102,8 +102,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
}
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.map_err(|e| CliError::Failed(e.to_string()))?
.into_bytes(),
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
@@ -379,4 +379,47 @@ mod tests {
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
}
#[test]
fn default_page_skips_leading_divider() {
let mut doc = Document::new("divider");
let page = doc.first_page().unwrap();
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
let divider = doc.add_page("- Divider");
doc.pages.retain(|p| *p != divider);
doc.pages.insert(0, divider);
assert!(doc.is_divider(divider));
assert_eq!(page_named(&doc, None).unwrap(), page);
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
assert_eq!((pm.width(), pm.height()), (40, 30));
assert!(render_page(&doc, divider, 1.0).is_err());
}
#[test]
fn export_depth_errors_preserve_conversion_destinations() {
let mut doc = Document::new("export depth");
let page = doc.first_page().unwrap();
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for _ in 1..257 {
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
}
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
for ext in ["svg", "pdf"] {
let path = dir.join(format!("previous.{ext}"));
std::fs::write(&path, b"previous export").unwrap();
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
panic!("depth overflow must be an operation failure");
};
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
assert!(error.contains("select an inner layer"), "{ext}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
}
+64 -34
View File
@@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
);
let doc = s.doc().map_err(failed)?.clone();
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let b = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
.ok_or_else(|| failed("no button glyph to drop on"))?;
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
);
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
} else {
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
}
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
if let Some(nav) = nav {
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
} else {
table.put(col, "reset all changes on a nav", f64::NAN);
table.put(col, "detach a nav", f64::NAN);
}
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let drop_at = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
match drop_at {
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
None => f64::NAN,
},
);
} else {
table.put(col, "push a button's changes to its main", f64::NAN);
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
}
if let Some(nav) = nav {
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
} else {
table.put(col, "copy + paste a nav", f64::NAN);
}
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
table.put(col, "redo", mean(10, |_| _ = s.redo()));
// As a library: publish it, use it from another file, publish a change, accept the update.
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
// One directory per run, so concurrent runs in a process don't delete each other's files.
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
@@ -533,6 +555,14 @@ mod tests {
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_components_with_a_single_instance() {
let mut t = super::Table { rows: Vec::new() };
super::measure_components(&mut t, 0, 1).unwrap();
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_a_small_document() {
let mut t = super::Table { rows: Vec::new() };
+21
View File
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn run_takes_the_documented_gradient_shorthand() {
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
let d = tmp("gradient");
let script = d.join("s.json");
std::fs::write(
&script,
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
)
.unwrap();
let file = d.join("g.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
let json = d.join("g.json");
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
let doc = std::fs::read_to_string(&json).unwrap();
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
+59
View File
@@ -0,0 +1,59 @@
//! Depth refusal through the actual CLI, including its exit status and destination writes.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
use std::process::Command;
#[test]
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
let mut doc = Document::new("export depth");
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for depth in 1..257 {
let name = if depth == 1 { "inner-root" } else { "group" };
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
}
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let input = dir.join("deep.figcraft");
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
for ext in ["svg", "pdf"] {
let path = dir.join(format!("previous.{ext}"));
std::fs::write(&path, b"previous export").unwrap();
for verb in ["convert", "export"] {
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
command.arg(verb).arg(&input);
if verb == "export" {
command.arg("-o");
}
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
let inner = dir.join(format!("inner.{ext}"));
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
.arg("export")
.arg(&input)
.arg("-o")
.arg(&inner)
.args(["--node", "inner-root", "--no-system-fonts"])
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let bytes = std::fs::read(inner).unwrap();
if ext == "svg" {
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
} else {
assert!(bytes.starts_with(b"%PDF"));
}
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
std::fs::remove_dir_all(dir).unwrap();
}
+69 -2
View File
@@ -5,10 +5,16 @@
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
//! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood
//! of addresses costs bounded memory (and forgotten addresses start with full buckets).
//!
//! Join codes are short, so guessing them is held back three ways: an address may try
//! [`CODES_PER_MINUTE`] codes a minute, [`WRONG_CODES`] wrong ones in a row lock it out of codes for
//! [`CODE_LOCKOUT`] (links still join), and everyone together may get [`WRONG_CODES_PER_HOUR`]
//! wrong an hour (an attacker with many addresses is slowed to that; while it lasts, people join
//! with their links).
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::Instant;
use std::time::{Duration, Instant};
use figcraft_collab_net::hub::{Block, ip_blocks};
use figcraft_collab_net::net::Bucket;
@@ -28,6 +34,15 @@ 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;
/// Join codes one address may try a minute (right or wrong).
pub const CODES_PER_MINUTE: f64 = 10.0;
/// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes.
pub const WRONG_CODES: u32 = 10;
/// How long an address is locked out of join codes.
pub const CODE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
/// Wrong codes everyone together may try an hour.
pub const WRONG_CODES_PER_HOUR: f64 = 1_000.0;
/// The rates (see the server's settings).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AbuseConfig {
@@ -49,6 +64,8 @@ pub enum Action {
Report,
/// Downloading this many bytes.
Download(usize),
/// Trying a join code.
Code,
}
struct Address {
@@ -58,6 +75,12 @@ struct Address {
presence: Bucket,
reports: Bucket,
downloads: Bucket,
codes: Bucket,
/// Wrong join codes in a row, and when the last one was.
wrong: u32,
wrong_at: Instant,
/// Locked out of join codes until then.
locked: Option<Instant>,
used: Instant,
}
@@ -67,6 +90,8 @@ pub struct Abuse {
table: HashMap<Block, Address>,
/// Publishing without the key, everyone together.
publishes: Bucket,
/// Wrong join codes, everyone together.
wrong_codes: Bucket,
}
/// `n` every `period` seconds on average, and all `n` at once at most.
@@ -77,7 +102,44 @@ fn bucket(n: f64, period: f64) -> Bucket {
impl Abuse {
pub fn new(cfg: AbuseConfig) -> Self {
let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0);
Self { cfg, table: HashMap::new(), publishes }
Self { cfg, table: HashMap::new(), publishes, wrong_codes: bucket(WRONG_CODES_PER_HOUR, 3600.0) }
}
/// How long `ip` is still locked out of join codes (in any block it is in), if it is.
pub fn code_locked(&self, ip: IpAddr, now: Instant) -> Option<Duration> {
let until = ip_blocks(ip).into_iter().filter_map(|(k, _)| self.table.get(&k)?.locked).max()?;
Some(until.saturating_duration_since(now)).filter(|d| !d.is_zero())
}
/// Everyone together has wrong codes left to try (see [`WRONG_CODES_PER_HOUR`]).
pub fn codes_open(&mut self, now: Instant) -> bool {
let open = self.wrong_codes.take(now);
if open {
self.wrong_codes.give(1.0);
}
open
}
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
/// before it; enough wrong ones in a row lock the address out.
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
if !ok {
self.wrong_codes.take(now);
}
for (k, times) in ip_blocks(ip) {
let a = self.address(k, f64::from(times), now);
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
a.wrong += 1;
a.wrong_at = now;
if a.wrong >= WRONG_CODES.saturating_mul(times) {
a.wrong = 0;
a.locked = Some(now + CODE_LOCKOUT);
}
}
}
}
/// Whether `ip` may do `what` now (counting it, in every block it is in, when it may).
@@ -114,6 +176,10 @@ impl Abuse {
presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0),
reports: bucket(REPORTS_PER_HOUR * times, 3600.0),
downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times),
codes: bucket(CODES_PER_MINUTE * times, 60.0),
wrong: 0,
wrong_at: now,
locked: None,
used: now,
});
a.used = now;
@@ -128,6 +194,7 @@ impl Abuse {
Action::Presence => (&mut a.presence, 1.0),
Action::Report => (&mut a.reports, 1.0),
Action::Download(n) => (&mut a.downloads, n as f64),
Action::Code => (&mut a.codes, 1.0),
}
}
+2 -2
View File
@@ -344,7 +344,7 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
if rotate {
out.push_str("A new server key: the old one no longer works.\n");
}
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
let _ = writeln!(out, "Publish to this address (in FigCraft: File > Publish to server…):");
let _ = writeln!(out, " {}?key={key}", public.url());
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
if cfg.open_publish {
@@ -375,7 +375,7 @@ fn used(f: &FileRow) -> u64 {
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
if files.is_empty() {
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
return "No files yet (publish one from FigCraft: File > Publish to server…).\n".into();
}
match sort {
Sort::Id => {}
+86
View File
@@ -0,0 +1,86 @@
//! Join codes on the server: a short name for one share link, typed in place of the link.
//!
//! A code is 45 random bits ([`figcraft_collab::code`]). Like a link's token it is never stored:
//! a link keeps the code's hash, to find the link by, and its own token wrapped with the code, to
//! hand to whoever types the code (they then join as with the link). Both come from the code
//! stretched with SHA-256 ([`STRETCH`] rounds), so a copy of the data directory doesn't give the
//! codes away cheaply; guessing against a running server is held back by the limits on wrong codes
//! (`abuse.rs`).
use figcraft_collab::{code, same_secret};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Rounds of SHA-256 a code goes through before it is compared or used as a key.
pub const STRETCH: u32 = 10_000;
// A copy of the data directory mustn't give codes away cheaply.
const _: () = assert!(STRETCH >= 10_000);
/// Longest token a code can wrap (one SHA-256 output).
const MAX_TOKEN: usize = 32;
/// What a link keeps of its join code (`links.json`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sealed {
/// The stretched code's hash, hex: what a typed code is looked up by.
pub hash: String,
/// The link's token, wrapped with the stretched code (hex).
pub token: String,
}
/// A typed code, stretched: ready to look links up with (what it is found by, and the pad its
/// link's token is wrapped with).
pub struct Key {
hash: String,
pad: [u8; 32],
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
impl Key {
/// Stretch `code` (its symbols, as [`code::normalize`] gives them).
pub fn new(code: &str) -> Key {
let mut k: [u8; 32] = Sha256::new().chain_update(b"figcraft join code\0").chain_update(code.as_bytes()).finalize().into();
for _ in 0..STRETCH {
k = Sha256::digest(k).into();
}
let part = |what: &[u8]| -> [u8; 32] { Sha256::new().chain_update(what).chain_update(k).finalize().into() };
Key { hash: hex(&part(b"find\0")), pad: part(b"wrap\0") }
}
/// What a link with this code keeps as [`Sealed::hash`].
pub fn hash(&self) -> &str {
&self.hash
}
/// Wrap or unwrap `bytes` (the same operation: XOR with a pad only this code gives).
fn wrap(&self, bytes: &[u8]) -> Vec<u8> {
bytes.iter().zip(self.pad).map(|(b, p)| b ^ p).collect()
}
/// Keep `token` under this code.
pub fn seal(&self, token: &str) -> Result<Sealed, String> {
if token.len() > MAX_TOKEN {
return Err("this link's token is too long for a join code".into());
}
Ok(Sealed { hash: self.hash.clone(), token: hex(&self.wrap(token.as_bytes())) })
}
/// The token `sealed` keeps, when this is its code (compared in constant time).
pub fn open(&self, sealed: &Sealed) -> Option<String> {
if !same_secret(&self.hash, &sealed.hash) || sealed.token.len() > 2 * MAX_TOKEN {
return None;
}
let wrapped: Option<Vec<u8>> =
(0..sealed.token.len() / 2).map(|i| sealed.token.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
String::from_utf8(self.wrap(&wrapped?)).ok()
}
}
/// A new code's symbols, from the operating system's random numbers.
pub fn new_code() -> Result<String, String> {
figcraft_collab_net::token::random().map(code::online)
}
+404
View File
@@ -0,0 +1,404 @@
//! Join codes on the server: sharing online from one app and joining by code from another (real
//! links over loopback; stopping revokes the others' links and codes and disconnects them while
//! the owner stays in the file, and sharing again makes new ones for the same file), codes kept
//! only hashed, revoked with their link and gone with their file,
//! the limits on wrong codes, junk requests, and clients from before join codes.
use std::net::IpAddr;
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::code;
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::{Document, NodeKind};
use figcraft_engine::Session;
use figcraft_engine::live::Status;
use serde_json::{Value, json};
use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, WRONG_CODES, WRONG_CODES_PER_HOUR};
use crate::codes::Key;
use crate::config::Config;
use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store};
use crate::test_util::{Raw, TempDir, config, live, pump, session};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "[email protected]".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// An app whose server for online sharing is the one at `url`.
fn app(name: &str, url: &str) -> Session {
let mut s = session(name);
ex(&mut s, "pref.publishServer", json!({"url": url}));
s
}
fn layers(s: &Session) -> usize {
s.doc().map_or(0, |d| d.node_count())
}
fn failed(s: &Session) -> Option<String> {
match &s.live().status {
Status::Failed { reason } => Some(reason.clone()),
_ => None,
}
}
fn is_code(shown: &str) -> bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
let mut server = crate::start(&open(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = app("Ada", &url);
// Sharing: the file goes to the server in Preferences, and the server names its links.
let started = ex(&mut a, "collab.share", json!({}));
assert_eq!((&started["status"], &started["share"]["via"], &started["code"]), (&json!("connecting"), &json!("online"), &Value::Null));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
let shared = a.live_summary();
let (edit, view) = (shared["share"]["editCode"].as_str().unwrap().to_string(), shared["share"]["viewCode"].as_str().unwrap().to_string());
assert!(is_code(&edit) && is_code(&view) && edit != view, "{edit} {view}");
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
let p = a.live().published.clone().unwrap();
assert!(p.owner.is_some(), "the owner token is kept as before");
// Others get links of their own (the owner keeps the file's first ones): four in all.
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
let ids = |dir: &std::path::Path| -> Vec<String> {
let links: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("files").join(&p.file).join("links.json")).unwrap()).unwrap();
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
};
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
// On disk: hashes only, of the codes as of the links' tokens.
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 2);
// Someone types the edit code, the way people type.
let mut b = app("Bo", &url);
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(b.live_summary()["link"], edit_link.as_str(), "joined as with the shared link: reconnects need no code");
let before = layers(&a);
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a, &mut b], "Ada's edit", |s| layers(s[1]) == before + 1);
ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a, &mut b], "Bo's edit", |s| layers(s[0]) == before + 2);
// The view code lets someone look, not edit.
assert_eq!(ex(&mut a, "collab.share", json!({"access": "view"}))["code"], view.as_str());
let mut c = app("Cy", &url);
ex(&mut c, "collab.join", json!({"code": view}));
pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]) && layers(s[2]) == before + 2);
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("view"), &json!(true)));
assert!(c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
// A code nobody was given.
let mut d = app("Di", &url);
ex(&mut d, "collab.join", json!({"code": "000-000-000"}));
pump(&mut [&mut d], "the refusal", |s| failed(s[0]).is_some());
assert_eq!(failed(&d).unwrap(), NO_CODE);
// Stop sharing: the others are out at once and told why; the file and its owner stay.
assert_eq!(ex(&mut a, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
pump(&mut [&mut a, &mut b, &mut c], "the others out", |s| {
!s[1].live().status.active() && !s[2].live().status.active() && s[0].live().peers.is_empty()
});
for guest in [&b, &c] {
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
}
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
let folder = dir.path().join("files").join(&p.file);
assert!(folder.exists() && live(&a), "the file stays on the server and Ada stays in it");
let stopped = a.live_summary();
assert_eq!((&stopped["share"]["shared"], &stopped["code"], &stopped["published"]["file"]), (&json!(false), &Value::Null, &json!(p.file)));
assert_eq!(ids(dir.path()), before_stop[..2], "the owner's links are left, the shared ones are gone");
// Neither the codes nor the links get anyone in any more.
let mut e = app("Ed", &url);
for old in [&edit, &view] {
ex(&mut e, "collab.join", json!({"code": old}));
pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some());
assert_eq!(failed(&e).unwrap(), CODE_GONE);
}
ex(&mut e, "collab.join", json!({"link": edit_link}));
pump(&mut [&mut e], "the stopped link", |s| failed(s[0]).is_some_and(|why| why.contains("isn't valid")));
// The owner still edits the server's file.
ex(&mut a, "shape.rectangle", json!({"x": 200, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a], "the owner's edit confirmed", |s| layers(s[0]) == before + 3 && s[0].live().client().is_some_and(|c| c.pending() == 0));
// Sharing again: new codes and links for the same file (nothing is published twice).
let again = ex(&mut a, "collab.share", json!({}));
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
assert_eq!(ids(dir.path()).len(), 4);
ex(&mut b, "collab.join", json!({"code": edit2}));
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
// The old code is still gone.
ex(&mut e, "collab.join", json!({"code": edit}));
pump(&mut [&mut e], "the old code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
server.stop();
}
#[test]
fn a_keyed_share_gets_codes_and_stops_with_the_key() {
let dir = TempDir::new("codes-keyed");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
ex(&mut a, "collab.share", json!({"server": format!("{url}?key={}", crate::test_util::key(dir.path())), "name": "Team file"}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
let shown = a.live_summary()["code"].as_str().unwrap().to_string();
assert!(is_code(&shown));
assert!(!a.live_summary().to_string().contains("key="), "the key is nowhere in the summary");
let mut b = app("Bo", &url);
ex(&mut b, "collab.join", json!({"code": shown}));
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
// No owner token: the key the address carried stops the sharing (the file is the operator's).
assert_eq!(ex(&mut a, "collab.stop", json!({}))["shared"], false);
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active());
assert!(live(&a) && !a.live_summary().to_string().contains("key="));
ex(&mut b, "collab.join", json!({"code": shown}));
pump(&mut [&mut b], "the stopped code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
// Leaving keeps the file there.
ex(&mut a, "collab.leave", json!({}));
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1);
server.stop();
}
#[test]
fn codes_are_hashed_replaced_revoked_and_gone_with_the_file() {
let dir = TempDir::new("codes-store");
let cfg = open(dir.path());
let me = ip("192.0.2.7");
let (file, edit_token, view_link, shown, view_code) = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, edit, view, _owner) = store.publish_open(me, "Poster", &snapshot("Poster")).unwrap();
let shown = store.set_code(&file.id, &edit.token).unwrap();
let view_code = store.set_code(&file.id, &view.token).unwrap();
assert_ne!(shown, view_code, "a code per link");
assert_eq!((shown.len(), code::normalize(&shown).as_deref()), (code::ONLINE_LEN, Ok(shown.as_str())));
// The code gives the link's token back, and the token admits with the link's access.
assert_eq!(store.resolve_code(me, &shown).unwrap(), (file.id.clone(), edit.token.clone()));
let (f, t) = store.resolve_code(me, &code::grouped(&view_code).to_lowercase()).unwrap();
assert_eq!(store.admit(&f, &t).unwrap().access, figcraft_collab::Access::View);
// A token that isn't one of the file's links gets no code.
assert!(store.set_code(&file.id, "AAAAAAAAAAAAAAAAAAAAAA").unwrap_err().contains("isn't one of this file's links"));
assert!(store.set_code("0000000000000000", &edit.token).is_err());
(file.id, edit.token, view.link.id, shown, view_code)
};
// What is kept: the stretched code's hash and the wrapped token, nothing that is either.
let links = std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap();
assert!(!links.contains(&shown) && !links.contains(&edit_token), "{links}");
assert!(links.contains(Key::new(&shown).hash()));
// A restart keeps the codes (they are in the data directory).
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
assert_eq!(store.resolve_code(me, &shown).unwrap().1, edit_token);
// A new code for the link takes the old one's place.
let newer = store.set_code(&file, &edit_token).unwrap();
assert_ne!(newer, shown);
assert_eq!(store.resolve_code(me, &shown).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(me, &newer).unwrap().1, edit_token);
// Revoking a link revokes its code.
store.revoke(&file, &view_link).unwrap();
assert_eq!(store.resolve_code(me, &view_code).unwrap_err(), CODE_GONE);
// The file expiring takes the rest.
store.age(&file, 31 * 86_400);
assert_eq!(store.sweep(), vec![file.clone()]);
assert_eq!(store.resolve_code(me, &newer).unwrap_err(), CODE_GONE);
// A code never handed out is unknown, not expired.
assert_eq!(store.resolve_code(ip("192.0.2.8"), "ZZZZZZZZZ").unwrap_err(), NO_CODE);
}
#[test]
fn wrong_codes_lock_an_address_out_but_not_the_others() {
let dir = TempDir::new("codes-lockout");
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
let shown = store.set_code(&file.id, &edit.token).unwrap();
let guesser = ip("198.51.100.9");
for n in 0..WRONG_CODES {
assert_eq!(store.resolve_code(guesser, &code::online(u64::from(n) + 1)).unwrap_err(), NO_CODE, "guess {n}");
}
// Locked out: not even the right code is looked up for that address.
for typed in [code::online(77), shown.clone()] {
let e = store.resolve_code(guesser, &typed).unwrap_err();
assert!(e.contains("Too many wrong codes") && e.contains("15 minutes") && e.contains("invite link"), "{e}");
}
// Its link still joins, and other addresses' codes still work.
assert!(store.admit(&file.id, &edit.token).is_ok());
assert_eq!(store.resolve_code(ip("198.51.100.10"), &shown).unwrap().0, file.id);
// An IPv6 guesser is counted by its /64.
for n in 0..WRONG_CODES {
let from: IpAddr = format!("2001:db8:1:2::{n:x}").parse().unwrap();
let _ = store.resolve_code(figcraft_collab_net::hub::ip_key(from), &code::online(1000 + u64::from(n)));
}
let neighbour = figcraft_collab_net::hub::ip_key(ip("2001:db8:1:2:ffff::1"));
assert!(store.resolve_code(neighbour, &shown).unwrap_err().contains("Too many wrong codes"));
}
#[test]
fn the_limits_on_codes_over_time() {
let t0 = Instant::now();
let me = ip("198.51.100.9");
let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
// A right code forgives the wrong ones before it.
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(me, false, t0);
}
abuse.code_tried(me, true, t0);
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(me, false, t0);
}
assert_eq!(abuse.code_locked(me, t0), None);
// One more: locked for the lockout, then free again.
abuse.code_tried(me, false, t0);
assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT));
assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some());
assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None);
// Wrong codes long apart don't add up.
let other = ip("198.51.100.10");
for n in 0..3 * WRONG_CODES {
abuse.code_tried(other, false, t0 + (CODE_LOCKOUT + Duration::from_secs(1)) * n);
}
assert_eq!(abuse.code_locked(other, t0 + CODE_LOCKOUT * 4 * WRONG_CODES), None);
// Codes tried a minute, right or wrong (these limits hold on private servers too).
let typist = ip("198.51.100.11");
let tried = (0..100).filter(|_| abuse.allow(typist, Action::Code, t0)).count();
assert_eq!(tried as f64, CODES_PER_MINUTE);
assert!(abuse.allow(typist, Action::Code, t0 + Duration::from_secs(7)), "one more every six seconds");
// Everyone together: many addresses guessing pause codes for all (links still join).
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
assert!(abuse.codes_open(t0));
for n in 0..WRONG_CODES_PER_HOUR as u32 {
abuse.code_tried(IpAddr::from([10, 1, (n >> 8) as u8, n as u8]), false, t0);
}
assert!(!abuse.codes_open(t0));
assert!(abuse.codes_open(t0 + Duration::from_secs(60)), "it refills");
}
#[test]
fn everyone_guessing_pauses_codes() {
let dir = TempDir::new("codes-busy");
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
let shown = store.set_code(&file.id, &edit.token).unwrap();
// Junk costs no hashing, so a thousand wrong "codes" from a thousand addresses are quick.
for n in 0..WRONG_CODES_PER_HOUR as u32 {
assert_eq!(store.resolve_code(IpAddr::from([10, 2, (n >> 8) as u8, n as u8]), "not a code").unwrap_err(), NO_CODE);
}
assert_eq!(store.resolve_code(ip("192.0.2.99"), &shown).unwrap_err(), CODES_BUSY);
assert!(store.admit(&file.id, &edit.token).is_ok(), "links are not affected");
}
fn refused(raw: &mut Raw) -> String {
match raw.next() {
ServerMsg::Refused { reason } => reason,
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn junk_requests_are_refused_and_never_hurt_the_server() {
let dir = TempDir::new("codes-junk");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
let url = format!("ws://{addr}/");
let mut a = app("Ada", &url);
ex(&mut a, "collab.share", json!({}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
let ask = |code: &str, protocol: u32| {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::Resolve(Resolve { protocol, client_version: String::new(), code: code.to_string() }));
let why = refused(&mut raw);
assert!(raw.closed_within(Duration::from_secs(5)), "the connection closes after the answer");
why
};
// Junk in place of a code (each from this one address: after ten it is locked out).
for junk in ["", " ", "💥💥💥", "K7M-2QX", "../../etc/passwd", "K7M2QX9TDK7M2QX9TD"] {
assert_eq!(ask(junk, PROTOCOL), NO_CODE, "{junk:?}");
}
assert_eq!(ask(&"Z".repeat(4000), PROTOCOL), NO_CODE);
assert!(ask("K7M2QX9TD", 1).contains("isn't supported"));
// Asking for a code needs the file's owner token (or the key), and one of its link tokens.
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: "AAAAAAAAAAAAAAAAAAAAAA".into() }));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
let mut raw = Raw::connect(addr);
let owner = p.owner.clone().unwrap();
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
for token in ["", "AAAAAAAAAAAAAAAAAAAAAA", &"x".repeat(5000)] {
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: token.to_string() }));
assert!(
matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("isn't one of this file's links")))
);
}
// A code in place of a link's token joins nothing: codes only resolve.
let mut raw = Raw::connect(addr);
let shown = a.live_summary()["code"].as_str().unwrap().replace('-', "");
let hello = Hello {
protocol: PROTOCOL,
file: p.file.clone(),
token: shown,
name: "Mal".into(),
color: String::new(),
client_version: String::new(),
resume: None,
};
raw.send(&ClientMsg::Hello(hello));
assert!(refused(&mut raw).contains("isn't valid"));
// The session went on through all of it.
assert_eq!(a.live_summary()["status"], "live");
server.stop();
}
#[test]
fn clients_from_before_join_codes_are_served_as_before() {
let dir = TempDir::new("codes-old-client");
let mut server = crate::start(&open(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
// An app that never asks for codes (`collab.publish`, what older apps do): links only, and
// nothing about codes is kept for the file.
let mut a = session("Ada");
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
assert_eq!((&a.live_summary()["code"], &a.live_summary()["share"]), (&Value::Null, &Value::Null));
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
assert!(!links.contains("\"code\""), "{links}");
// Its links join as ever.
let mut b = crate::test_util::join(&p.view, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
server.stop();
// A data directory from before join codes (links without the field) opens unchanged.
let old = r#"[{"id":"0123456789abcdef","access":"edit","label":"Published (edit)","created":1,"hash":"00"}]"#;
let path = dir.path().join("files").join(&p.file).join("links.json");
std::fs::write(&path, old).unwrap();
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
assert_eq!(store.links(&p.file).unwrap().len(), 1);
assert_eq!(store.resolve_code(ip("192.0.2.1"), "K7M2QX9TD").unwrap_err(), NO_CODE);
}
@@ -235,6 +235,21 @@ fn a_file_cant_grow_past_its_cap_through_batches() {
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
drop(raw);
server.stop();
// Everything confirmed opens again with the same settings, after compacting on the way out.
let kept = client.document().nodes().count();
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
let welcome = loop {
match raw.next() {
w @ ServerMsg::Welcome { .. } => break w,
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
_ => {}
}
};
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
server.stop();
}
@@ -785,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() {
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
server.stop();
}
#[test]
fn a_library_publish_that_fails_keeps_serving_the_old_version() {
let dir = TempDir::new("libfail");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
// Version 1, then a changed library as version 2.
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1);
let files = dir.path().join(crate::store::FILES).join(&file.id);
// The description can't be written: a directory sits where its temporary file goes.
let tmp = crate::disk::temp_of(&files.join(crate::disk::META));
std::fs::create_dir(&tmp).unwrap();
assert!(store.publish_library(&file.id, &v2).is_err());
let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
let served = |reply: LibraryReply| match reply {
LibraryReply::Library { version, archive } => (version, archive),
other => panic!("{other:?}"),
};
assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes");
// A crash right after the archive was written leaves it on disk, the description unchanged.
std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap();
drop(store);
std::fs::remove_dir(&tmp).unwrap();
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart");
// Publishing again works, and serves the new version.
assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2);
assert_eq!(served(fetch(&mut store)), (2, v2));
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
}
+5
View File
@@ -10,6 +10,7 @@
pub mod abuse;
pub mod cli;
pub mod codes;
pub mod config;
pub mod directory;
pub mod disk;
@@ -90,12 +91,16 @@ mod background_tests;
#[cfg(test)]
mod cli_tests;
#[cfg(test)]
mod codes_tests;
#[cfg(test)]
mod directory_tests;
#[cfg(test)]
mod hardening_tests;
#[cfg(test)]
mod library_tests;
#[cfg(test)]
mod owned_tests;
#[cfg(test)]
mod public_tests;
#[cfg(test)]
mod server_tests;
+194
View File
@@ -0,0 +1,194 @@
//! Your online files against a real server: a file shared online is recorded with its owner
//! token; after a restart of the app the server says what became of it (`Admin::Owned`, one
//! request for all the files), it opens again as its owner in step with everyone who joined by
//! code, its sharing stops and starts from the record, and removing it takes it off the server's
//! disk. The request itself: only a file's own token learns about it, junk never hurts, and an
//! owner's question opens nothing (a file isn't kept alive by being asked about).
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, MAX_OWNED, OpenHosting, PROTOCOL, ServerMsg};
use figcraft_engine::Prefs;
use serde_json::{Value, json};
use crate::config::Config;
use crate::test_util::{Raw, TempDir, config, live, pump, same, session};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, ..config(dir) }
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// An open hosting connection that names no file.
fn hosting(addr: std::net::SocketAddr) -> Raw {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw
}
fn owned(raw: &mut Raw, files: Vec<(String, String)>) -> AdminReply {
raw.send(&ClientMsg::Admin(Admin::Owned { files }));
match raw.next() {
ServerMsg::Admin(r) => *r,
other => panic!("answered {other:?}"),
}
}
/// Ada shares her open file online through the server at `url`: the app with the file live.
fn share(url: &str) -> figcraft_engine::Session {
let mut a = session("Ada");
ex(&mut a, "pref.publishServer", json!({"url": url}));
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
ex(&mut a, "collab.share", json!({}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some()));
a
}
/// The app quits (its sessions end with it); the next run has only the preferences.
fn restart(a: figcraft_engine::Session, name: &str) -> figcraft_engine::Session {
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&a.prefs).unwrap()).unwrap();
drop(a);
let mut next = session(name);
next.prefs = prefs;
next
}
#[test]
fn a_shared_file_is_its_owners_again_after_the_app_restarts() {
let dir = TempDir::new("owned-restart");
let mut server = crate::start(&open(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let a = share(&url);
let code = a.live_summary()["code"].as_str().unwrap().to_string();
let file = a.prefs.online_files[0].file.clone();
let owner = a.prefs.online_files[0].owner.clone();
assert_eq!(a.live().published.as_ref().and_then(|p| p.owner.clone()), Some(owner.clone()));
// The app quits. The next run knows the file from its preferences alone.
let mut a = restart(a, "Ada");
let list = ex(&mut a, "collab.files", json!({}));
assert_eq!((&list["files"][0]["file"], &list["files"][0]["state"], &list["files"][0]["code"]), (&json!(file), &json!("unknown"), &json!(code)));
// The server says it is there, and still shared.
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"], &list["problems"]), (&json!("online"), &json!(true), &json!([])));
assert!(list["files"][0]["expires"].as_u64().is_some_and(|e| e > crate::store::now()));
assert!(!list.to_string().contains(&owner));
// Open it: the owner is in the file, synced, in a tab of its own.
let opened = ex(&mut a, "collab.openFile", json!({"file": file}));
assert!(!opened.to_string().contains(&owner));
pump(&mut [&mut a], "the owner back in", |s| live(s[0]));
assert_eq!(a.docs.len(), 2);
assert!(!a.live_summary().to_string().contains(&owner), "the token stays in the record");
// Someone joins with the code given out before the restart, and sees the owner's edits.
let mut b = session("Bo");
ex(&mut b, "pref.publishServer", json!({"url": url}));
ex(&mut b, "collab.join", json!({"code": code}));
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
ex(&mut a, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
pump(&mut [&mut a, &mut b], "the edit at Bo's", |s| same(s[0].doc().unwrap(), s[1].doc().unwrap()));
// Stop sharing from the list (no tab needed): Bo is out, the owner stays, the code is dead.
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false);
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active() && live(s[0]));
assert_eq!(a.live_summary()["share"]["shared"], false, "the open tab follows");
let mut c = session("Cy");
ex(&mut c, "pref.publishServer", json!({"url": url}));
ex(&mut c, "collab.join", json!({"code": code}));
pump(&mut [&mut c], "the old code refused", |s| matches!(s[0].live().status, figcraft_engine::live::Status::Failed { .. }));
// Start again: a new code for the same file.
let again = ex(&mut a, "collab.share", json!({}));
let new_code = again["code"].as_str().unwrap().to_string();
assert_ne!(new_code, code);
assert_eq!(ex(&mut a, "collab.files", json!({}))["files"][0]["code"], new_code);
let mut d = session("Di");
ex(&mut d, "pref.publishServer", json!({"url": url}));
ex(&mut d, "collab.join", json!({"code": new_code}));
pump(&mut [&mut a, &mut d], "Di in with the new code", |s| live(s[1]));
// Rename, then remove: the file leaves the server's disk and the list; the tab's session ends.
ex(&mut a, "collab.manageFile", json!({"file": file, "action": "rename", "name": "Landing"}));
assert_eq!(ex(&mut a, "collab.files", json!({"refresh": true}))["files"][0]["name"], "Landing");
assert!(dir.path().join("files").join(&file).exists());
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "remove"}))["done"], true);
assert!(!dir.path().join("files").join(&file).exists(), "gone from the data directory");
assert!(a.prefs.online_files.is_empty() && !a.live().status.active() && a.docs.len() == 2, "the copy stays open");
server.stop();
}
#[test]
fn a_file_that_expired_or_was_removed_elsewhere_shows_as_gone() {
let dir = TempDir::new("owned-gone");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = share(&url);
let file = a.prefs.online_files[0].file.clone();
ex(&mut a, "collab.leave", json!({}));
let mut a = restart(a, "Ada");
// The operator removes it.
crate::cli::run(&crate::cli::Cli { config: cfg.clone(), command: crate::cli::Command::RemoveFile { file: file.clone() } }).unwrap();
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"]), (&json!("gone"), &json!(false)));
assert!(a.execute("collab.openFile", &json!({"file": file})).unwrap_err().to_string().contains("no longer on its server"));
assert_eq!(ex(&mut a, "collab.forgetFile", json!({"file": file}))["onServer"], false);
// The server stops: the list says it couldn't be reached and keeps what it has.
let mut b = share(&url);
ex(&mut b, "collab.leave", json!({}));
server.stop();
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
assert_eq!((&list["files"][0]["state"], &list["problems"][0]["state"]), (&json!("online"), &json!("offline")));
assert_eq!(list["files"].as_array().unwrap().len(), 1);
}
#[test]
fn only_a_files_own_token_learns_about_it() {
let dir = TempDir::new("owned-ask");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let a = share(&format!("ws://{addr}/"));
let (file, owner) = (a.prefs.online_files[0].file.clone(), a.prefs.online_files[0].owner.clone());
let opened_before = std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap();
let mut raw = hosting(addr);
let pair = |f: &str, o: &str| (f.to_string(), o.to_string());
let reply = owned(
&mut raw,
vec![pair(&file, &owner), pair(&file, "WrongTokenWrongToken00"), pair("nosuchfile", &owner), pair("", ""), pair("../../etc", "x")],
);
let AdminReply::Owned(list) = reply else { panic!("answered {reply:?}") };
assert_eq!(list.len(), 5, "one answer per pair, in order");
let first = list[0].as_ref().expect("its own token");
assert_eq!(first.name, "Untitled");
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
assert!(first.expires.is_some());
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
assert_eq!(owned(&mut raw, vec![]), AdminReply::Owned(vec![]));
let many = vec![pair(&file, &owner); MAX_OWNED + 1];
assert!(matches!(owned(&mut raw, many), AdminReply::Failed { reason } if reason.contains("at most")));
assert!(matches!(owned(&mut raw, vec![pair(&file, &owner); MAX_OWNED]), AdminReply::Owned(l) if l.len() == MAX_OWNED));
// Junk ids and tokens (long, control characters, other scripts) are just not theirs.
let junk = vec![pair(&"x".repeat(100_000), &"y".repeat(100_000)), pair("\u{0}\u{7}", "\u{1F4A5}"), pair(&file, &format!("{owner}\u{0}"))];
assert!(matches!(owned(&mut raw, junk), AdminReply::Owned(l) if l.iter().all(Option::is_none)));
// On that connection the token still manages nothing: it named no file when it opened.
raw.send(&ClientMsg::Admin(Admin::Remove { file: file.clone() }));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { .. })));
assert!(dir.path().join("files").join(&file).exists());
// Asking opened nothing: when the file was last opened is as it was.
assert_eq!(std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap(), opened_before);
// A connection that manages one file with its token may ask too; the server key as well.
let mut one = Raw::connect(addr);
one.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: file.clone(), owner: owner.clone() }));
assert!(matches!(one.next(), ServerMsg::Hosting(_)));
assert!(matches!(owned(&mut one, vec![pair(&file, &owner)]), AdminReply::Owned(l) if l[0].is_some()));
drop(a);
server.stop();
}
#[test]
fn a_server_that_isnt_open_to_the_public_answers_that_nothing_is_owned() {
let dir = TempDir::new("owned-private");
let mut server = crate::start(&config(dir.path())).unwrap();
let mut raw = hosting(server.addr());
assert!(matches!(owned(&mut raw, vec![("f".into(), "t".into())]), AdminReply::Owned(l) if l == [None]));
server.stop();
}
+8 -8
View File
@@ -60,8 +60,8 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
assert_eq!(info["idleDays"], 30.0);
// Published without a key: links, an owner token shown once, and an expiry date.
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some() && s[0].live.hosting.is_some());
let p = a.live.published.clone().unwrap();
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live().hosting.is_some());
let p = a.live().published.clone().unwrap();
let owner = p.owner.clone().expect("an owner token");
assert_eq!(owner.len(), 22, "128 bits");
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
@@ -73,7 +73,7 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
// Someone joining hears about the hosting too (with the file's expiry).
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live.hosting.as_ref().is_some_and(|h| h.expires.is_some()));
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live().hosting.as_ref().is_some_and(|h| h.expires.is_some()));
// The owner lists, makes, revokes links and renames the file.
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
@@ -98,9 +98,9 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
raw.send(&ClientMsg::Admin(Admin::Listing));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
// Removing it disconnects everyone in it.
// Removing it disconnects everyone in it (whoever removed it just leaves: no failure there).
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
pump(&mut [&mut a, &mut b], "everyone told", |s| s.iter().all(|x| x.live_summary()["status"] == "failed"));
pump(&mut [&mut a, &mut b], "everyone told", |s| s[0].live_summary()["status"] == "off" && s[1].live_summary()["status"] == "failed");
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
assert!(!dir.path().join("files").join(&p.file).exists());
server.stop();
@@ -260,10 +260,10 @@ fn participants_report_files_to_the_operator() {
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
let mut b = join(&p.view, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live.hosting.is_some());
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live().hosting.is_some());
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));
+16 -16
View File
@@ -34,8 +34,8 @@ fn publish_then_edit_and_view_together() {
// Nothing changes it until it is on the server.
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
assert!(e.contains("being published"), "{e}");
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
assert_eq!(a.live_summary()["readOnly"], false);
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
@@ -43,7 +43,7 @@ fn publish_then_edit_and_view_together() {
// Bo joins with the edit link, Cy with the view link.
let mut b = join(&p.edit, "Bo");
let mut c = join(&p.view, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live().peers.len() == 2);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(c.live_summary()["access"], "view");
assert_eq!(c.live_summary()["readOnly"], true);
@@ -64,9 +64,9 @@ fn publish_then_edit_and_view_together() {
assert!(c.begin_interaction("Move").is_err());
assert!(same(&before, c.doc().unwrap()));
// Cy is still seen: presence goes both ways.
let cy = c.live.info.as_ref().unwrap().me;
let cy = c.live().info.as_ref().unwrap().me;
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live().presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
// A client that skips the app's check is refused by the server.
let view = Invite::parse(&p.view).unwrap();
let mut raw = Raw::connect(addr);
@@ -90,7 +90,7 @@ fn publish_then_edit_and_view_together() {
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
// Leaving keeps a local copy.
c.execute("collab.leave", &json!({})).unwrap();
assert_eq!(c.live.status, Status::Off);
assert_eq!(c.live().status, Status::Off);
assert!(c.doc().unwrap().contains(rect));
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
@@ -106,21 +106,21 @@ fn a_restart_mid_session_loses_no_confirmed_edit() {
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
let (ada, bo) = (a.live().info.as_ref().unwrap().me, b.live().info.as_ref().unwrap().me);
// The server stops: both reconnect, and what Ada makes meanwhile waits.
server.stop();
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live().status, Status::Reconnecting { .. })));
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
cfg.listen = addr.to_string();
let mut server = start_again(&cfg);
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
});
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
assert_eq!(a.live().info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live().info.as_ref().unwrap().me, bo, "so did Bo");
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
// Someone new gets it all from the restarted server.
let mut c = join(&p.view, "Cy");
@@ -160,20 +160,20 @@ fn revoked_links_stop_working_at_once() {
assert_eq!((files.len(), files[0].people), (1, 3));
// Revoked: Cy is out at once, the others stay.
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
});
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
assert!(live(&b));
// The link no longer joins.
c.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live().status, Status::Failed { reason } if reason.contains("isn't valid")));
// Managing needs the key.
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("wrong server key"), "{e}");
// Removing the file ends it for everyone in it.
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b], "everyone out", |s| {
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
s.iter().all(|x| matches!(&x.live().status, Status::Failed { reason } if reason.contains("removed")))
});
server.stop();
}
+242 -41
View File
@@ -6,10 +6,12 @@
//! <data>/server.json where the running server listens (for the command line)
//! <data>/server.key the server key: managing files (publish, links, removal)
//! <data>/files/<id>/meta.json name, created, updated, sequence number
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token,
//! and a join code's hash and wrapped token (see `codes`)
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
//! <data>/files/<id>/log.bin what changed since (see `disk`)
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
//! <data>/files/<id>/library-<version>.bin its team library, when one was published (a `.figcraft`
//! archive; `library.bin` from servers before versioned names)
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
//! <data>/plugins/ the plugin directory (see `directory`)
//! <data>/blocked.json addresses the operator blocked
@@ -28,7 +30,7 @@
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
//! never deleted.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::{Cursor, Write};
use std::net::IpAddr;
@@ -36,8 +38,8 @@ use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use figcraft_collab::message::{
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, NewLink, PluginReply,
PluginReq, ReportInfo, Usage,
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, MAX_OWNED, NewLink, OwnedFile,
PluginReply, PluginReq, ReportInfo, Usage,
};
use figcraft_collab::{Change, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
@@ -46,6 +48,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::abuse::{Abuse, AbuseConfig, Action};
use crate::codes::{self, Sealed};
use crate::directory::{Directory, DirectoryConfig};
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
@@ -58,6 +61,12 @@ pub const REPORTS: &str = "reports.json";
/// Most addresses blocked, and most reports kept (the oldest go first).
const MAX_BLOCKED: usize = 100_000;
const MAX_REPORTS: usize = 1_000;
/// Most join codes remembered as no longer valid.
const MAX_GONE_CODES: usize = 10_000;
/// What typing a join code that opens nothing is answered with, by why.
pub const NO_CODE: &str = "No session with that code";
pub const CODE_GONE: &str = "That code has expired or its sharing was stopped";
pub const CODES_BUSY: &str = "Join codes are paused on this server for a while (too many wrong ones were tried): join with the invite link";
/// Longest report reason kept (characters).
const MAX_REASON: usize = 500;
/// How often idle files are looked for (seconds), besides at startup.
@@ -171,7 +180,7 @@ impl Meta {
}
}
/// What a file's team library is (`library.bin` holds it).
/// What a file's team library is ([`library_file`] holds it).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct LibraryMeta {
id: String,
@@ -188,6 +197,9 @@ struct Link {
created: u64,
/// SHA-256 of the token, hex.
hash: String,
/// Its join code, when one was handed out (never the code itself).
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<Sealed>,
}
impl Link {
@@ -245,10 +257,18 @@ pub struct Store {
usage: BTreeMap<String, UsageCache>,
blocked: BTreeSet<IpAddr>,
reports: Vec<ReportInfo>,
/// Hashes of join codes that no longer open anything (their link was revoked, their file
/// removed or expired, or a newer code took their place): typing one says so.
// shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the
// data directory if people ask why their code "never existed".
gone_codes: VecDeque<String>,
/// When idle files are looked for next (seconds since 1970).
next_sweep: u64,
/// Compactions so far (tests count them).
compactions: u64,
/// Open files closed by a compaction cut short (their snapshot replaced, their log not):
/// reported at the next flush, so nothing appended after it is confirmed.
cut_short: Vec<String>,
/// The free space last seen on the data directory's disk, and when.
free: Option<(Instant, Option<u64>)>,
_lock: File,
@@ -327,25 +347,30 @@ impl Store {
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let (directory, problems) = Directory::open(root, cfg.directory);
let (directory, startup_problems) = Directory::open(root, cfg.directory);
let abuse = Abuse::new(cfg.hosting.abuse);
let mut store = Store {
root: root.to_path_buf(),
cfg,
key,
files: BTreeMap::new(),
problems,
problems: Vec::new(),
directory,
abuse,
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
usage: BTreeMap::new(),
blocked: BTreeSet::new(),
reports: Vec::new(),
gone_codes: VecDeque::new(),
next_sweep: 0,
compactions: 0,
cut_short: Vec::new(),
free: None,
_lock: lock,
};
for problem in startup_problems {
store.note(problem);
}
store.scan(&dir);
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
@@ -363,7 +388,7 @@ impl Store {
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
Ok(list) => list,
Err(e) => {
self.problems.push(e);
self.note(e);
Vec::new()
}
}
@@ -372,7 +397,7 @@ impl Store {
fn scan(&mut self, dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
Err(e) => return self.note(format!("can't list {}: {e}", dir.display())),
};
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
ids.sort();
@@ -381,18 +406,18 @@ impl Store {
continue;
}
if !valid_id(&id) {
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
self.note(format!("skipped {}: not a file folder of this server", path.display()));
continue;
}
if self.files.len() >= self.cfg.max_files {
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
continue;
}
match self.check(&path) {
Ok(entry) => {
self.files.insert(id, entry);
}
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
Err(why) => self.note(format!("skipped file {id}: {why}")),
}
}
}
@@ -409,7 +434,7 @@ impl Store {
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec<Link> =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.snapshot_cap())?)?;
// A damaged log only loses its end; it is reported when the file opens.
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
&& let Ok(log) = disk::read_log(&bytes)
@@ -604,6 +629,26 @@ impl Store {
.collect()
}
/// Owner token `owner` manages file `file` (one published without the key).
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);
@@ -755,6 +800,101 @@ impl Store {
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).
fn link_of(entry: &Entry, token: &str) -> Option<usize> {
let presented = hash(token);
let mut found = None;
for (i, l) in entry.links.iter().enumerate() {
if same_secret(&presented, &l.hash) {
found = Some(i);
}
}
found
}
/// These join codes no longer open anything: typing one says so.
fn forget_codes(&mut self, codes: impl IntoIterator<Item = Sealed>) {
self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
self.gone_codes.drain(..extra);
}
/// Where the link with the join code `key` is: its file, its place among the file's links
/// and its token (every code is compared, so timing doesn't tell which one matched).
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
let mut found = None;
for (id, e) in &self.files {
for (i, l) in e.links.iter().enumerate() {
if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
found = Some((id.clone(), i, token));
}
}
}
found
}
/// A new join code for the link of `file` whose token is `token` (it takes the place of the
/// link's earlier code): the code, shown this once.
pub fn set_code(&mut self, file: &str, token: &str) -> Result<String, String> {
let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
// 45 random bits: one already in use is all but impossible, and never handed out twice.
let (code, key) = loop {
let code = codes::new_code()?;
let key = codes::Key::new(&code);
if self.find_code(&key).is_none() {
break (code, key);
}
};
let sealed = key.seal(token)?;
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
let before = link.code.replace(sealed);
let id = link.id.clone();
if let Err(e) = write_json(&path, &entry.links) {
if let Some(l) = entry.links.get_mut(at) {
l.code = before;
}
return Err(format!("couldn't keep the join code: {e}"));
}
self.forget_codes(before);
log::info!("event=code file={file} link={id}");
Ok(code)
}
/// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
/// the address (see `abuse`), and nothing is looked up for an address that is locked out.
pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
let now = Instant::now();
if let Some(left) = self.abuse.code_locked(ip, now) {
return Err(format!(
"Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
left.as_secs() / 60 + 1
));
}
if !self.abuse.allow(ip, Action::Code, now) {
return Err("Too many codes tried from your address: wait a minute and try again".into());
}
if !self.abuse.codes_open(now) {
return Err(CODES_BUSY.into());
}
// Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
let found = key.as_ref().and_then(|k| self.find_code(k));
self.abuse.code_tried(ip, found.is_some(), now);
match found {
Some((file, _, token)) => {
log::info!("event=code_used file={file} ip={ip}");
Ok((file, token))
}
None => {
let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
Err(if gone { CODE_GONE } else { NO_CODE }.into())
}
}
}
/// Revoke a link.
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
let path = self.dir(file).join(LINKS);
@@ -766,6 +906,7 @@ impl Store {
return Err(format!("couldn't revoke the link: {e}"));
}
log::info!("revoked link {link} of file {file}");
self.forget_codes(gone.code);
Ok(())
}
@@ -774,6 +915,7 @@ impl Store {
pub fn remove(&mut self, file: &str) -> Result<(), String> {
// Closed first: an open log would keep the folder from being deleted on Windows.
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
drop(entry);
let dir = self.dir(file);
fs::remove_dir_all(&dir)
@@ -785,11 +927,12 @@ 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).
fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
if have == Some(lib.version) {
return Ok(LibraryReply::Unchanged { version: lib.version });
}
let path = library_file(&dir, lib.version);
let size = fs::metadata(&path).map_or(0, |m| m.len());
let now = Instant::now();
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
@@ -831,14 +974,24 @@ impl Store {
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
}
}
// The archive first: a description never names a version whose library isn't there (a
// failed description write leaves the older number, which the next publish follows).
disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
// 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)
}
@@ -952,7 +1105,7 @@ impl Store {
fn load(&mut self, file: &str) -> Result<Server, String> {
self.touch(file);
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
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;
@@ -990,23 +1143,37 @@ impl Store {
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.
_ => self.rewrite(file, generation + 1, &server)?,
_ => self.rewrite(file, generation + 1, &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.
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, String> {
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
let dir = self.dir(file);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(generation, &state)?;
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| format!("couldn't write the snapshot: {e}"))?;
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
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();
@@ -1059,8 +1226,19 @@ impl Store {
}
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(e) => log::error!("file {file}: compaction failed: {e}"),
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
}
}
@@ -1068,7 +1246,7 @@ impl Store {
/// Those are closed here (their pending records dropped: nobody was told of them) and open
/// again from what is on disk.
fn flush_all(&mut self) -> Vec<String> {
let mut failed = Vec::new();
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) {
@@ -1084,6 +1262,13 @@ impl Store {
}
}
/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced
/// before it failed (then the old log no longer goes with it).
struct RewriteError {
why: String,
snapshot_replaced: bool,
}
/// 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).
fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
@@ -1104,6 +1289,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
}
}
/// 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) }
}
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
disk::write_atomic(path, &text).map_err(|e| e.to_string())
@@ -1112,21 +1309,15 @@ fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), Strin
/// A new link and its token.
fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> {
let token = token::secret()?;
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token))
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token))
}
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
let entry = self.entry(file)?;
let presented = hash(token);
// Every link is compared (in constant time), so timing doesn't tell which one matched.
let mut found = None;
for l in &entry.links {
if same_secret(&presented, &l.hash) {
found = Some(Admission { access: l.access, link: Some(l.id.clone()) });
}
}
found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
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> {
@@ -1177,6 +1368,8 @@ impl Backend for Store {
if dirty {
self.compact(file, server);
}
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
@@ -1234,9 +1427,7 @@ impl Backend for Store {
if file.is_empty() && owner.is_empty() {
return Ok(self.host_info(None));
}
let presented = hash(owner);
let owns = self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h));
if !owns {
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)))
@@ -1257,10 +1448,12 @@ impl Backend for Store {
}
};
}
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"),
};
@@ -1297,6 +1490,10 @@ impl Backend for Store {
},
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());
@@ -1342,6 +1539,10 @@ impl Backend for Store {
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
}
+44
View File
@@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() {
let _ = disk::read_log(&bytes);
}
}
/// The first page's name in `server`'s document.
fn page_name(server: &Server) -> String {
let d = server.replica().document();
d.node(d.first_page().unwrap()).unwrap().name.clone()
}
#[test]
fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let dir = TempDir::new("cutshort");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let folder = dir.path().join(FILES);
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
// The next compaction writes its snapshot, then can't start its log.
let blocker = disk::temp_of(&folder.join(&id).join(LOG));
fs::create_dir(&blocker).unwrap();
// What the hub would confirm: every change whose flush didn't report the file.
let mut confirmed = String::new();
let mut failed = false;
let names = ["P0", "P1", "P2", "After 1", "After 2"];
for name in names {
rename(&mut server, &mut a, name);
store.changed(&id, &mut server);
if store.flush().contains(&id) {
failed = true;
break;
}
confirmed = name.to_string();
}
assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}");
// A crash, then the disk recovers.
drop(store);
fs::remove_dir(&blocker).unwrap();
let mut store = Store::open(dir.path(), c).unwrap();
let restored = store.open(&id).unwrap();
// Every confirmed change is there (the snapshot may also hold the one that was cut short).
let at = |n: &str| names.iter().position(|x| *x == n);
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
+3 -3
View File
@@ -91,7 +91,7 @@ pub fn key(dir: &Path) -> String {
}
pub fn live(s: &Session) -> bool {
s.live.status == Status::Live && s.live.syncing()
s.live().status == Status::Live && s.live().syncing()
}
/// `a` publishes its open file to the server at `addr`; the links.
@@ -99,8 +99,8 @@ pub fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
a.live.published.clone().unwrap()
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
a.live().published.clone().unwrap()
}
pub fn join(link: &str, name: &str) -> Session {
@@ -0,0 +1,40 @@
use figcraft_server::config::Config;
use figcraft_server::store::{FILES, Store};
#[test]
fn startup_problem_retention() {
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
assert!(!root.exists());
let result = (|| -> Result<(), String> {
for count in [3u32, 200, 201] {
let data = root.join(count.to_string());
for i in 0..count {
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
}
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
cfg.check()?;
let store = Store::open(&data, cfg.store())?;
let expected = (count as usize).min(200);
let problems = store.problems().to_vec();
let no_loaded_files = store.is_empty();
drop(store);
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
return Err("unexpected diagnostic or loaded file".into());
}
if problems.len() != expected {
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
}
// The latest problems stay, in scan order: the first folder's is the one dropped.
let first_kept = count as usize - expected;
for (n, p) in problems.iter().enumerate() {
let id = format!("{:016x}", first_kept + n);
if !p.contains(&id) {
return Err(format!("problem {n} should be about {id}: {p}"));
}
}
}
Ok(())
})();
std::fs::remove_dir_all(&root).unwrap();
assert!(result.is_ok(), "{}", result.unwrap_err());
}
+2 -1
View File
@@ -24,5 +24,6 @@ log = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+53 -3
View File
@@ -40,7 +40,7 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
Ok(Box::new(WebShell { fig }))
}),
)
@@ -69,14 +69,35 @@ impl eframe::App for WebShell {
}
}
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
/// goes through egui's own web clipboard handling.
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
/// events with their text (a page can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
}
impl Services for WebServices {
fn downloads(&self) -> bool {
true
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
log::error!("couldn't download {name}: {e:?}");
false
}
}
}
fn clipboard_set_text(&mut self, s: &str) {
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba);
}
fn open_url(&mut self, url: &str) {
let Some(window) = web_sys::window() else { return };
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
@@ -108,3 +129,32 @@ impl Services for WebServices {
held
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{
let o = web_sys::BlobPropertyBag::new();
o.set_type("application/octet-stream");
o
})?;
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?;
a.set_href(&url);
a.set_download(name);
body.append_child(&a)?;
a.click();
a.remove();
// Revoked later, not at once: some browsers cancel a download whose URL goes before it starts.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
// Without the timer the URL just stays until the page closes.
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS);
Ok(())
}
/// How long a download's object URL is kept (milliseconds).
const REVOKE_DOWNLOAD_MS: i32 = 40_000;
+7
View File
@@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
[dev-dependencies]
wat = { workspace = true }
+6 -1
View File
@@ -1,4 +1,5 @@
//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere).
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
//! (no-op elsewhere).
fn main() {
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
@@ -14,6 +15,10 @@ fn main() {
if let Err(e) = res.compile() {
println!("cargo::warning=winresource: {e}");
}
// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers
// nest up to 256 deep in an export, and that recursion needs the same room everywhere.
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
}
let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();
println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");
+57
View File
@@ -0,0 +1,57 @@
//! macOS open-documents and quit Apple events (#289).
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft`
//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and
//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's
//! launch event that leaves winit's delegate alone, behind a safe main-thread API.
use std::path::PathBuf;
use figcraft_ui_egui::FigApp;
use fmv_macos_events::{Event, Inbox, Registration};
use serde_json::json;
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => open_documents(fig, paths),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
for p in paths {
if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With,
//! a drop on the Dock icon) open as File › Open opens them (#289).
use std::path::PathBuf;
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, NoServices};
use serde_json::json;
use crate::apple_events::open_documents;
#[test]
fn documents_from_finder_open_and_leave_the_home_screen() {
let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("BLA-001.figcraft");
let mut s = Session::new();
s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
app.ui.home = true;
// A file that can't be read is reported and doesn't keep the others from opening.
open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]);
let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect();
assert_eq!(paths, [Some(file)]);
assert!(!app.ui.home, "the opened document shows");
assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error);
let _ = std::fs::remove_dir_all(&dir);
}
+77 -17
View File
@@ -1,7 +1,7 @@
//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -12,6 +12,20 @@ use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
type CtxSlot = Arc<Mutex<Option<egui::Context>>>;
const MAX_LINE: usize = 32 * 1024 * 1024;
const MAX_CONNECTIONS: usize = 8;
const MAX_QUEUED_REQUESTS: usize = 8;
const TIMEOUT: Duration = Duration::from_secs(60);
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
let mut queue = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
queue.retain(|request| !request.is_cancelled());
let count = queue.len().min(MAX_QUEUED_REQUESTS.saturating_sub(target.len()));
target.extend(queue.drain(..count));
}
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
@@ -23,9 +37,9 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
};
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in listener.incoming().flatten() {
let (q, c) = (queue.clone(), ctx.clone());
let _ = std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || serve(stream, q, c));
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
}
});
if let Err(e) = spawned {
@@ -33,37 +47,83 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
}
}
fn serve(stream: TcpStream, queue: Queue, ctx: CtxSlot) {
let Ok(read_half) = stream.try_clone() else { return };
let mut out = stream;
let reader = BufReader::new(read_half);
for line in reader.lines() {
let Ok(line) = line else { break };
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
return;
}
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
let _ = serve(stream, queue, ctx, timeout);
}) {
Ok(worker) => workers.push(worker),
Err(e) => log::error!("control connection thread: {e}"),
}
}
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(out.try_clone()?);
let mut line = Vec::new();
loop {
line.clear();
if (&mut reader).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
break;
}
if line.last() == Some(&b'\n') {
line.pop();
}
if line.len() > MAX_LINE {
writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
break;
}
let Ok(line) = std::str::from_utf8(&line) else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
let reply = match serde_json::from_str::<Value>(line) {
Ok(v) => {
let id = v.get("id").cloned().unwrap_or(Value::Null);
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
let (tx, rx) = std::sync::mpsc::channel();
queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(ControlRequest { id: id.clone(), method, params, reply: tx });
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
drop(pending);
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match rx.recv_timeout(Duration::from_secs(60)) {
match rx.recv_timeout(timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(_) => json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}),
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
}
}
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
Err(e) => {
// A line that isn't JSON ends the connection: a web page can make the browser send
// an HTTP request here, and its header lines must not let the JSON lines of its body
// through as commands.
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
break;
}
};
if writeln!(out, "{reply}").is_err() {
break;
}
writeln!(out, "{reply}")?;
}
Ok(())
}
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;
+278
View File
@@ -0,0 +1,278 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::io::Read;
use std::net::Shutdown;
use std::sync::mpsc;
use std::time::Instant;
fn pair() -> (TcpStream, TcpStream) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
client.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
(client, listener.accept().unwrap().0)
}
fn queue() -> Queue {
Arc::new(Mutex::new(Vec::new()))
}
fn context() -> CtxSlot {
Arc::new(Mutex::new(None))
}
fn reply(reader: &mut BufReader<TcpStream>) -> Value {
let mut line = String::new();
assert!(reader.read_line(&mut line).unwrap() > 0);
serde_json::from_str(&line).unwrap()
}
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
}
#[test]
fn rejects_an_oversized_unterminated_frame_without_dispatch() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
// Generate bytes as they are written; no giant input string and no terminating newline.
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "message too large"}));
assert_eq!(reader.read(&mut [0]).unwrap(), 0);
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty());
}
#[test]
fn valid_frames_keep_their_order_ids_and_parameters() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
let mut reader = BufReader::new(client);
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
assert_eq!(r.id, "test");
assert_eq!(r.method, "engine.execute");
assert_eq!(r.params, json!({"command": "shape.rectangle"}));
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
client.write_all(http.as_bytes()).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
}
#[test]
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
let (mut client, server) = pair();
let pending = queue();
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| request()));
let mut ui_queue = (0..MAX_QUEUED_REQUESTS - 1).map(|_| request()).collect();
drain_queue(&pending, &mut ui_queue);
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS - 1);
pending.lock().unwrap().push(request());
drain_queue(&pending, &mut ui_queue);
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
ui_queue.clear();
drain_queue(&pending, &mut ui_queue);
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
let (mut client, server) = pair();
let (done, finished) = mpsc::channel();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
client.write_all(bytes).unwrap();
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
assert!(pending.lock().unwrap().is_empty());
worker.join().unwrap().unwrap();
}
}
#[test]
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let pending = queue();
let ctx = context();
let mut workers = Vec::new();
let mut clients = Vec::new();
for _ in 0..MAX_CONNECTIONS {
let (client, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
clients.push(client);
}
assert_eq!(workers.len(), MAX_CONNECTIONS);
let (mut extra, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
assert_eq!(workers.len(), MAX_CONNECTIONS);
for client in clients {
client.shutdown(Shutdown::Both).unwrap();
}
let start = Instant::now();
while workers.iter().any(|worker| !worker.is_finished()) {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
let (mut client, server) = pair();
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
client.shutdown(Shutdown::Both).unwrap();
workers.pop().unwrap().join().unwrap();
}
#[test]
fn a_client_that_stops_reading_cannot_hold_a_worker() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let (done, finished) = mpsc::channel();
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!("x".repeat(8 * 1024 * 1024))) }).unwrap();
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
worker.join().unwrap().unwrap();
}
#[test]
fn a_limit_sized_frame_is_accepted_at_eof() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
client.write_all(message).unwrap();
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
client.shutdown(Shutdown::Write).unwrap();
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
assert_eq!(r.id, 1);
assert_eq!(r.method, "ui.inspect");
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
assert_eq!(reply(&mut BufReader::new(client)), json!({"id": 1, "ok": true, "result": {}}));
worker.join().unwrap().unwrap();
}
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let mut app = FigApp::new(figcraft_engine::Session::new(), Box::new(NoServices));
let window = windows::open(&mut app, &ctx, Some(0), None, None).unwrap();
let pending = queue();
let mut clients = Vec::new();
let mut workers = Vec::new();
for id in 0..MAX_QUEUED_REQUESTS {
let (mut client, server) = pair();
let q = pending.clone();
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
clients.push(BufReader::new(client));
}
let start = Instant::now();
while pending.lock().unwrap().len() < MAX_QUEUED_REQUESTS {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
drain_queue(&pending, &mut app.control_queue);
control::drain(&mut app, &ctx);
assert_eq!(app.control_queue.len(), MAX_QUEUED_REQUESTS, "the other window has not drawn");
for client in &mut clients {
assert!(reply(client)["error"].as_str().unwrap().contains("timed out"));
client.get_ref().shutdown(Shutdown::Write).unwrap();
}
assert!(app.control_queue.iter().all(ControlRequest::is_cancelled));
let expired = app.control_queue.pop().unwrap();
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
r
}));
let (mut client, server) = pair();
let q = pending.clone();
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
let start = Instant::now();
while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
drain_queue(&pending, &mut app.control_queue);
assert_eq!(app.control_queue.len(), 1, "expired UI requests no longer occupy the cap");
control::drain(&mut app, &ctx);
let mut client = BufReader::new(client);
assert_eq!(reply(&mut client)["ok"], true);
client.get_ref().shutdown(Shutdown::Write).unwrap();
assert!(app.control_queue.is_empty());
// A window that finally draws must discard its expired command before dispatch, too.
let documents = app.session.docs.len();
app.windows.current = window;
app.control_queue.push(expired);
control::drain(&mut app, &ctx);
assert!(app.control_queue.is_empty());
assert_eq!(app.session.docs.len(), documents);
for worker in workers {
worker.join().unwrap().unwrap();
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Which graphics API draws the window.
//!
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
//! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
//!
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
//! systems other than Windows keep eframe's defaults.
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
use eframe::wgpu::Backends;
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
/// on Windows, else eframe's `defaults`.
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
match requested {
Some(requested) => requested,
None if windows => WINDOWS,
None => defaults,
}
}
/// Offer wgpu [`backends`] instead of eframe's defaults.
pub fn configure(options: &mut WgpuConfiguration) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
let offered = &mut create.instance_descriptor.backends;
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
}
}
+32
View File
@@ -0,0 +1,32 @@
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
//! driver crashed the app as its window opened), and nothing changed anywhere else.
use eframe::wgpu::Backends;
use crate::graphics::backends;
/// What eframe offers when `WGPU_BACKEND` isn't set.
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
#[test]
fn windows_is_offered_direct3d_and_not_vulkan() {
let offered = backends(None, true, DEFAULTS);
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
}
#[test]
fn wgpu_backend_names_the_backend_on_every_system() {
for windows in [true, false] {
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
}
}
}
#[test]
fn other_systems_keep_eframes_defaults() {
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
}
+68 -11
View File
@@ -9,18 +9,30 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
mod control_server;
mod gamepad;
mod graphics;
mod recovery;
mod services;
mod startup;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
#[cfg(test)]
mod args_tests;
#[cfg(test)]
mod graphics_tests;
#[cfg(test)]
mod recovery_online_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod services_tests;
#[cfg(test)]
mod startup_tests;
use std::sync::{Arc, Mutex};
@@ -38,6 +50,9 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
/// Documents and quit requests from Finder, the Dock and Open With.
#[cfg(target_os = "macos")]
apple_events: fmv_macos_events::Inbox,
}
impl eframe::App for App {
@@ -49,9 +64,10 @@ impl eframe::App for App {
}
}
let title = with_app(|fig| {
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
fig.control_queue.extend(q.drain(..));
drop(q);
control_server::drain_queue(&self.queue, &mut fig.control_queue);
figcraft_ui_egui::control::drain_hidden(fig, ctx);
#[cfg(target_os = "macos")]
apple_events::poll(&self.apple_events, fig, ctx);
// Closing the window asks about unsaved changes (and keeps the app running while other
// windows are open).
figcraft_ui_egui::windows::on_close_request(fig, ctx);
@@ -78,9 +94,9 @@ impl eframe::App for App {
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)));
services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))));
}
fn on_exit(&mut self) {
@@ -129,24 +145,37 @@ fn main() -> eframe::Result {
}
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
let recovery: recovery::Shared = Arc::new(Mutex::new(recovery_root.as_deref().and_then(recovery::Recovery::start)));
// The preferences first: they hold the online files a recovered document may be one of.
let recovery = recovery::Shared::default();
let svc = services::Desktop::load(recovery.clone());
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
let recovered = recovery_root.as_deref().map(|r| recovery::recover_all(r, &mut session)).unwrap_or_default();
let mut copies = recovery_root.as_deref().and_then(recovery::Recovery::start);
// Copies of online files stay until their files arrive from their servers.
if let Some(r) = copies.as_mut() {
r.adopt(recovered.waiting);
}
*recovery::lock(&recovery) = copies;
// Plugins installed from a server's plugin directory live with the user's settings.
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
// Invite links open this app (Windows registers per user at run time; installed builds by
// default, never a build from source unless asked).
session.live.links = figcraft_collab_net::links::system();
session.invite_links_startup();
if session.docs.is_empty() {
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
// tips and templates; later runs start in a new file.
let first_run = svc.prefs.is_none() && args.join.is_none();
if session.docs.is_empty() && !first_run {
let _ = session.execute("file.new", &json!({}));
}
// The interface language from the first frame on, and for the message when the window can't
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
fig.ui.home = false;
fig.note_recovered(recovered);
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
}
@@ -155,8 +184,11 @@ fn main() -> eframe::Result {
control_server::start(p, queue.clone(), ctx_slot.clone());
}
let icon = services::window_icon();
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
// and StartupWMClass (FreeBSD's package matches the binary's name instead).
let mut viewport = egui::ViewportBuilder::default()
.with_title("FigCraft")
.with_app_id("ai.storyteller.figcraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
@@ -172,7 +204,32 @@ fn main() -> eframe::Result {
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
Ok(Box::new(App {
queue,
ctx_slot,
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
}))
}),
);
// Say why instead of quitting silently: release builds on Windows have no console.
if let Err(e) = &result {
startup::failed(e, port.is_some());
}
result
}
+276 -52
View File
@@ -4,7 +4,20 @@
//! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
//! whose lock is free belong to runs that ended without closing: their copies open again as
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of
//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run
//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder
//! with its last copy. Closing the app normally removes its own.
//!
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
//! after the copy it describes): the server, the file's id, and who the app was in the file with
//! the last change of the server's it had; never a token. The next start joins that file again
//! as its owner (`collab.recoverFile`) and **keeps the copy until the outcome is known**: the
//! server has everything in it (the copy goes), the copy's own unconfirmed changes were put on
//! top of the server's file and sent, the copy was opened beside the file because both had
//! changed, or the file couldn't be joined and the copy was opened as any other. Whatever a copy
//! became is copied into this run's folder before the old copy is removed. Without the note, or
//! when the file is no longer recorded, the copy opens as any other.
use std::collections::HashMap;
use std::fs::File;
@@ -12,7 +25,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use figcraft_engine::Session;
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
/// Seconds between copies of a document that keeps changing.
@@ -31,77 +45,219 @@ pub struct Recovery {
dir: PathBuf,
/// Held while the app runs: a free lock means the run that wrote the folder is gone.
_lock: File,
/// The revision each document was last copied at, and the write in flight.
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
/// What was copied of each document, and the write in flight.
written: HashMap<u64, Copy>,
/// When (UI clock) the next copies are due.
next: f64,
/// Copies of online files from earlier runs, kept until their outcome is known.
waiting: Vec<Waiting>,
}
/// The state of the copy of one document: only a write that succeeded counts as a copy.
#[derive(Default)]
struct Copy {
/// The [`stamp`] whose copy was written.
done: Option<Vec<u64>>,
/// The write in flight: the stamp it copies, and whether it succeeded once it ends.
flight: Option<(Vec<u64>, JoinHandle<bool>)>,
}
impl Copy {
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
fn reap(&mut self) {
if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) {
self.join();
}
}
/// Wait for the write in flight and take in its result.
fn join(&mut self) {
if let Some((stamp, h)) = self.flight.take()
&& h.join().unwrap_or(false)
{
self.done = Some(stamp);
}
}
/// Whether `stamp` has neither been copied nor is being copied.
fn pending(&self, stamp: &[u64]) -> bool {
self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp)
}
}
/// A copy from an earlier run, kept until what it became is safe.
pub struct Waiting {
until: Until,
copy: PathBuf,
/// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy
/// left directly in the root by older versions).
_lock: Option<Arc<File>>,
}
/// What a kept copy waits for.
enum Until {
/// One of your online files, joined again as this session: its outcome.
Session(u64),
/// Opened as document `uid`: this run's own copy of it (or its save or close).
Copied(u64),
}
/// What [`recover_all`] brought back.
#[derive(Default)]
pub struct Recovered {
/// Documents opened from their copies.
pub copies: usize,
/// The copies kept until what they became is safe ([`Recovery::adopt`]).
pub waiting: Vec<Waiting>,
}
impl Recovery {
/// Take this run's folder under `root` (`None` when it can't be created or locked: no copies).
/// A crashed run with the same process id may still hold its folder (its copies kept): then
/// another name is taken.
pub fn start(root: &Path) -> Option<Self> {
let dir = root.join(std::process::id().to_string());
std::fs::create_dir_all(&dir).ok()?;
let lock = File::create(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0 })
let pid = std::process::id();
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
/// Keep earlier runs' copies until what they became is safe.
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
self.waiting.extend(waiting);
}
/// Copy the documents that changed since their last copy and drop the copies of documents that
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
/// an idle window can sleep).
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
self.written.values_mut().for_each(Copy::reap);
self.settle(s);
// A join on its way settles within its timeouts: look again soon. A copy waiting for this
// run's own is looked at again with the next copies.
let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_)));
let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) };
if now < self.next {
return pending.then_some(self.next - now);
return self.pending(s).then_some(self.next - now).or(settling);
}
self.write(s, false);
self.next = now + INTERVAL;
None
// A copy that failed is tried again at the next turn, without another edit.
settling.or(self.pending(s).then_some(INTERVAL))
}
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
fn pending(&self, s: &Session) -> bool {
s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d))))
}
/// Remove earlier runs' copies once what they became is safe: copied here (the write
/// succeeded), saved or closed. An online file's copy goes once its outcome is known and every
/// document is safe, since which of them it became isn't known.
pub fn settle(&mut self, s: &Session) {
let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id));
if self.waiting.iter().any(joined) {
self.write(s, true);
}
let all_safe = s.docs.iter().all(|d| self.safe(s, d));
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until {
Until::Session(_) => joined(w) && all_safe,
Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)),
});
self.waiting = waiting;
done.into_iter().for_each(remove_kept);
}
/// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write
/// that succeeded) with no write in flight.
fn safe(&self, s: &Session, d: &DocState) -> bool {
!needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none())
}
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
pub fn write(&mut self, s: &Session, wait: bool) {
for d in &s.docs {
let path = self.dir.join(format!("{}.figcraft", d.uid));
if !needs_copy(d) {
if self.written.remove(&d.uid).is_some() {
let note = path.with_extension(ONLINE);
if !needs_copy(s, d) {
if let Some(mut w) = self.written.remove(&d.uid) {
// A write still running would bring the copy back after it was removed.
w.join();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&note);
}
continue;
}
let entry = self.written.entry(d.uid).or_insert((0, None));
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
if entry.0 == d.revision || busy {
let entry = self.written.entry(d.uid).or_default();
// An emergency save must copy the newest revision: let the write in flight finish first.
if wait {
entry.join();
} else {
entry.reap();
}
// A write still in flight (for any revision) is waited for; a failed one is tried again.
let stamp = stamp(s, d);
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
continue;
}
let doc = d.doc.clone();
let job = move || write_atomic(&path, &doc);
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
// Which online file it is and what of the server's this very copy has (a line each:
// the server, the file's id, then the session, client id and last change), while it
// is one. Written after the copy, never before: a note must not promise more of the
// server's changes than its copy holds.
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
let job = move || {
if !write_atomic(&path, &doc, &versions, &branches) {
return false;
}
let _ = match online {
Some(text) => std::fs::write(&note, text),
None => std::fs::remove_file(&note),
};
true
};
// The stamp counts as copied only once its write succeeded.
if wait {
job();
entry.1 = None;
if job() {
entry.done = Some(stamp);
}
} else {
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h));
}
entry.0 = d.revision;
}
// Closed documents.
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
let dir = &self.dir;
self.written.retain(|uid, _| {
self.written.retain(|uid, w| {
let keep = open.contains(uid);
if !keep {
w.join();
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
}
keep
});
}
/// A normal quit: the documents were saved or their changes discarded on purpose.
/// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`.
#[cfg(test)]
pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle<bool>) {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
let Self { dir, _lock, written, .. } = self;
let Self { dir, _lock, written, waiting, .. } = self;
// The documents earlier runs' copies became were saved or discarded too.
waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept);
// Windows removes a folder only once nothing in it is open: copies being written first.
for h in written.into_values().filter_map(|w| w.1) {
for (_, h) in written.into_values().filter_map(|w| w.flight) {
let _ = h.join();
}
drop(_lock);
@@ -109,35 +265,96 @@ impl Recovery {
}
}
/// A document whose changes would be lost (a branch tab's edits are in its main file).
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
d.is_dirty() && d.branch_of.is_none()
}
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return };
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
/// Remove a kept copy and its note, and its folder with its last copy (while another is kept
/// there, its lock is held).
fn remove_kept(w: Waiting) {
let _ = std::fs::remove_file(&w.copy);
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1);
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last);
drop(w);
if let Some(dir) = dir {
let _ = std::fs::remove_file(dir.join(LOCK));
let _ = std::fs::remove_dir(dir);
}
}
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
/// A copy that can't be read stays where it is. Returns how many documents came back.
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
/// file isn't recorded any more, or is open already): then it opens as any other copy.
fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
let note = std::fs::read_to_string(file.with_extension(ONLINE)).ok()?;
let mut lines = note.lines().map(str::trim);
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
fn needs_copy(s: &Session, d: &DocState) -> bool {
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
}
/// The tabs showing branches of the file in tab `uid`.
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
}
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
/// only its own revision). A new copy is due when they change.
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
}
/// Write `doc` with its version history and branches to `path` through a temporary file; false
/// when it couldn't be written (the copy before it is still there).
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
}
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
/// unsaved copies.
#[cfg(test)]
pub fn recover(root: &Path, s: &mut Session) -> usize {
let Ok(entries) = std::fs::read_dir(root) else { return 0 };
let mut n = 0;
let mut open = |file: &Path, s: &mut Session| {
if file.extension().is_some_and(|e| e == "figcraft") && s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
n += 1;
let _ = std::fs::remove_file(file);
recover_all(root, s).copies
}
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept
/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your
/// online files, while its file is joined again. A copy that can't be read stays where it is.
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
let mut out = Recovered::default();
let Ok(entries) = std::fs::read_dir(root) else { return out };
let mut open = |file: &Path, lock: Option<&Arc<File>>, s: &mut Session| {
if file.extension().is_none_or(|e| e != "figcraft") {
return;
}
// One of your online files comes back from its server, its copy kept until then.
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) });
return;
}
let before: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
out.copies += 1;
if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) {
out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() });
}
}
};
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
open(&path, s);
open(&path, None, s);
continue;
}
// Still running (another window of the app), or not ours to take.
@@ -145,19 +362,26 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
if lock.try_lock().is_err() {
continue;
}
let lock = Arc::new(lock);
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
if f.extension().is_some_and(|e| e == "tmp") {
// A copy cut off mid-write; the one before it is still there.
let _ = std::fs::remove_file(f);
} else {
open(f, s);
open(f, Some(&lock), s);
}
}
drop(lock);
// Empty now unless a copy couldn't be read (kept for the next start).
let _ = std::fs::remove_file(path.join(LOCK));
let _ = std::fs::remove_dir(&path);
// The notes go with their copies (one whose copy stays, stays with it).
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
let _ = std::fs::remove_file(f);
}
// Empty now unless a copy couldn't be read, or is kept (its lock held).
if Arc::strong_count(&lock) == 1 {
drop(lock);
let _ = std::fs::remove_file(path.join(LOCK));
let _ = std::fs::remove_dir(&path);
}
}
n
out
}
+379
View File
@@ -0,0 +1,379 @@
//! Recovery of your online files never loses an edit. The copy of a document that was one of them
//! when a run ended without closing has a note beside it (the server, the file, who the app was
//! there and the server's last change it had; never a token). The next start joins the file again
//! as its owner and keeps the copy until the outcome is known: the copy is ahead of the server
//! (its changes are applied on top and sent, as the client the app was), the same (it goes), both
//! changed (it opens beside the file as a recovered copy), or the server can't be reached (it
//! opens as any other copy). Without the record the copy opens as any other.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_engine::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Resume, Role, SessionInfo, Status};
use serde_json::json;
use crate::recovery::{Recovery, recover, recover_all};
const SERVER: &str = "wss://share.example/";
const OWNER: &str = "OwnerSecretOwnerSecret";
const EDIT: &str = "figcraft://join?server=wss://share.example/&file=f1&token=EditSecretEditSecret00";
/// What the link was asked, what the app sent, and the events handed out next (as the session
/// started last).
#[derive(Default)]
struct Script {
joined: Vec<(String, Option<Resume>)>,
sent: Vec<ClientMsg>,
events: Vec<Event>,
session: u64,
}
struct Link(Arc<Mutex<Script>>);
impl LiveLink for Link {
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, session: u64, link: &str, name: &str) -> Result<(), String> {
self.join_resuming(session, link, name, None)
}
fn join_resuming(&mut self, session: u64, link: &str, _: &str, resume: Option<Resume>) -> Result<(), String> {
let mut s = self.0.lock().unwrap();
s.session = session;
s.joined.push((link.to_string(), resume));
Ok(())
}
fn publish(&mut self, session: u64, _: PublishOptions) -> Result<(), String> {
self.0.lock().unwrap().session = session;
Ok(())
}
fn leave(&mut self, _: u64) {}
fn presence(&mut self, _: u64, _: &Presence) {}
fn send(&mut self, _: u64, msg: ClientMsg) {
self.0.lock().unwrap().sent.push(msg);
}
fn poll(&mut self) -> Vec<(u64, Event)> {
let mut s = self.0.lock().unwrap();
let session = s.session;
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
}
}
type Shared = Arc<Mutex<Script>>;
fn session() -> (Session, Shared) {
let mut s = Session::new();
let script = Shared::default();
s.live.install(Box::new(Link(script.clone())));
(s, script)
}
fn root(name: &str) -> PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-recovery-online-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
r
}
fn info() -> SessionInfo {
SessionInfo {
role: Role::Guest,
me: 1,
name: "Me".into(),
color: "#0D99FF".into(),
file_name: "Homepage".into(),
pages: vec![],
link: EDIT.into(),
url: String::new(),
address: SERVER.into(),
network: true,
access: Access::Edit,
}
}
/// Someone joins `server` (as the client `resume` names, if it knows it): their id and welcome.
fn join(server: &mut Server, resume: Option<Resume>) -> (u32, ServerMsg) {
let ClientMsg::Hello(mut hello) = Client::hello("f1", "", "Me", "") else { panic!("no hello") };
hello.resume = resume;
let (id, outs) = server.join(hello).unwrap();
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
(id, welcome)
}
/// What the app sent since the last call reaches the server; whether every batch was accepted.
/// The server's confirmations wait for the app's next poll.
fn deliver(script: &Shared, server: &mut Server, from: u32) -> bool {
let sent = std::mem::take(&mut script.lock().unwrap().sent);
let mut accepted = true;
for m in sent {
for o in server.receive(from, m) {
match o {
Outgoing::To(_, ServerMsg::Reject { .. }) => accepted = false,
Outgoing::All(m @ ServerMsg::Changes { .. }) => script.lock().unwrap().events.push(Event::Sync(m)),
_ => {}
}
}
}
accepted
}
fn layers(doc: &figcraft_engine::doc::Document) -> usize {
doc.node(doc.pages[0]).map_or(0, |p| p.children.len())
}
/// A run whose open file was shared online: the app, what it sent, the server and the app's id
/// there. One rectangle is on the server.
fn shared() -> (Session, Shared, Server, u32) {
let (mut s, script) = session();
s.execute("pref.publishServer", &json!({"url": SERVER})).unwrap();
s.execute("file.rename", &json!({"name": "Homepage"})).unwrap();
s.execute("collab.share", &json!({})).unwrap();
let published = Published {
server: "share.example".into(),
file: "f1".into(),
name: "Homepage".into(),
edit: EDIT.into(),
view: String::new(),
owner: Some(OWNER.into()),
expires: None,
};
let mut server = Server::new(s.doc().unwrap().clone(), "", Limits::default());
let (me, welcome) = join(&mut server, None);
script.lock().unwrap().events.extend([Event::Published(published), Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
s.live_poll();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(deliver(&script, &mut server, me));
s.live_poll();
assert_eq!(layers(server.document()), 1);
(s, script, server, me)
}
/// The run ends without closing, its copies written; the folder it leaves (as another process's).
fn crash(s: &Session, root: &Path) -> PathBuf {
let mut r = Recovery::start(root).unwrap();
r.write(s, true);
drop(r);
let left = root.join("crashed");
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
left
}
fn copies(dir: &Path, ext: &str) -> usize {
std::fs::read_dir(dir).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == ext)).count())
}
/// The next run: only the preferences survive. Its copies of online files wait for their files.
fn restart(crashed: &Session, root: &Path) -> (Session, Shared, Recovery, usize) {
let (mut next, script) = session();
next.prefs = serde_json::from_value(serde_json::to_value(&crashed.prefs).unwrap()).unwrap();
let recovered = recover_all(root, &mut next);
let mut r = Recovery::start(root).unwrap();
r.adopt(recovered.waiting);
(next, script, r, recovered.copies)
}
/// The file arrives in the next run from `server`, as the join asked.
fn arrive(next: &mut Session, script: &Shared, server: &mut Server) -> u32 {
let resume = script.lock().unwrap().joined.last().and_then(|j| j.1);
let (id, welcome) = join(server, resume);
script.lock().unwrap().events.extend([Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
next.live_poll();
id
}
#[test]
fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
let root = root("ahead");
let (mut s, script, mut server, me) = shared();
// An edit the server never got (the connection was down, or the app died first).
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
let at = s.online_of(s.docs[0].uid).unwrap().2;
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
let (mut next, script, mut r, opened) = restart(&s, &root);
assert_eq!(opened, 0, "nothing comes back as a plain copy");
{
let sc = script.lock().unwrap();
let [(link, resume)] = sc.joined.as_slice() else { panic!("joined {}", sc.joined.len()) };
assert_eq!(link, EDIT, "joined again as its owner");
assert_eq!(*resume, Some(Resume { seq: u64::MAX, ..at }), "as the client it was, asking for the whole file");
}
// The copy stays until the outcome is known.
assert!(r.tick(&next, 0.0).is_some(), "it looks again while the file is on its way");
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1));
// The file arrives: the server lacks the ellipse; it goes on top as one edit and is sent.
let id = arrive(&mut next, &script, &mut server);
assert_eq!(id, me, "the server gave the id back");
assert_eq!(next.message.take().as_deref(), Some("Recovered your changes to Homepage that hadn't reached the server"));
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "in the file's own tab, no copy beside it");
assert!(deliver(&script, &mut server, id), "the server takes it (new layers are that client's to create)");
assert_eq!(layers(server.document()), 2, "everyone gets the edit");
assert_eq!(next.execute("collab.peers", &json!({})).unwrap()["status"], "live");
// Only now the old copy goes, after this run has copied what it became.
r.tick(&next, 1.0);
assert!(!left.exists(), "the crashed run's folder is gone");
let mine = root.join(std::process::id().to_string());
assert_eq!((copies(&mine, "figcraft"), copies(&mine, "online")), (1, 1), "covered by this run's own copy");
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_online_copy_stays_while_what_it_became_cant_be_copied() {
let root = root("ahead-blocked");
let (mut s, script, mut server, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
let (mut next, script, mut r, _) = restart(&s, &root);
arrive(&mut next, &script, &mut server);
assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet");
// Something is in the way of this run's copy: writing it fails.
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid));
std::fs::create_dir_all(&mine).unwrap();
r.tick(&next, 1.0);
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk");
// Once it can be written, the old copy goes.
std::fs::remove_dir(&mine).unwrap();
next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap();
r.tick(&next, 2.0);
assert!(!left.exists());
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_the_server_already_has_is_discarded() {
let root = root("equal");
let (mut s, script, mut server, me) = shared();
// Sent and accepted, but the app died before it heard so.
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(deliver(&script, &mut server, me));
let left = crash(&s, &root);
let (mut next, script, mut r, _) = restart(&s, &root);
assert_eq!(copies(&left, "figcraft"), 1);
arrive(&mut next, &script, &mut server);
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "the file as the server has it");
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing to send");
assert!(!next.docs[1].is_dirty());
r.tick(&next, 1.0);
assert!(!left.exists());
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_that_cant_be_merged_opens_beside_the_file() {
let root = root("conflict");
let (mut s, script, mut server, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
// Meanwhile someone else changes the file on the server.
let (other, welcome) = join(&mut server, None);
let mut them = Client::join(welcome).unwrap();
let mut doc = them.document().clone();
let page = doc.pages[0];
doc.create(page, figcraft_engine::doc::NodeKind::Rectangle, "Theirs", 50.0, 50.0, 5.0, 5.0).unwrap();
let batch = them.edit(&doc).unwrap().unwrap();
assert!(!server.receive(other, batch).iter().any(|o| matches!(o, Outgoing::To(_, ServerMsg::Reject { .. }))));
let (mut next, script, mut r, _) = restart(&s, &root);
arrive(&mut next, &script, &mut server);
// The server's file is untouched; the copy is a tab of its own, named as what it is.
let names: Vec<&str> = next.docs.iter().map(|d| d.doc.name.as_str()).collect();
assert_eq!(names, ["Untitled", "Homepage", "Homepage (recovered copy)"]);
assert_eq!(next.active, 1, "the file stays in front");
assert_eq!(layers(&next.docs[1].doc), 2, "the rectangle and theirs");
assert_eq!(layers(&next.docs[2].doc), 2, "the rectangle and the ellipse that never got there");
assert!(next.docs[2].is_dirty() && next.docs[2].path.is_none());
assert_eq!(
next.message.take().as_deref(),
Some("Homepage changed on the server before your last changes reached it: they are open beside it as “Homepage (recovered copy)”")
);
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing is forced onto the server");
r.tick(&next, 1.0);
assert!(!left.exists());
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1, "the recovered tab has this run's copy");
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_whose_server_cant_be_reached_opens_as_any_other() {
let root = root("offline");
let (mut s, script, _, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
let (mut next, script, mut r, _) = restart(&s, &root);
r.tick(&next, 0.0);
assert_eq!(copies(&left, "figcraft"), 1, "kept while the join is on its way");
script.lock().unwrap().events.push(Event::Status(Status::Failed { reason: "Couldn't reach the server (timed out)".into() }));
next.live_poll();
let ds = next.docs.last().unwrap();
assert_eq!((ds.doc.name.as_str(), layers(&ds.doc), ds.is_dirty(), ds.path.is_none()), ("Homepage", 2, true, true));
assert_eq!(next.active, next.docs.len() - 1);
assert!(next.message.take().unwrap_or_default().contains("your recovered copy is open"));
assert_eq!(next.prefs.online_files.len(), 1, "the record stays: the file is still yours on the server");
r.tick(&next, 1.0);
assert!(!left.exists());
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1);
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_server_that_forgot_the_client_gets_no_layers_it_would_refuse() {
let root = root("forgot");
let (mut s, script, _, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
crash(&s, &root);
// The server lost its session (restored from a backup): the same file, another session, so
// the app joins as someone new and can't create the layers of who it was.
let mut restored = Server::new(Session::new().doc().unwrap().clone(), "", Limits::default());
let (mut next, script, r, _) = restart(&s, &root);
arrive(&mut next, &script, &mut restored);
assert_eq!(next.docs.last().map(|d| d.doc.name.as_str()), Some("Homepage (recovered copy)"));
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn without_its_record_the_copy_opens_as_any_other() {
let root = root("fallback");
let (s, _, _, _) = shared();
crash(&s, &root);
// The record was forgotten (or the preferences lost): the copy is what there is.
let (mut next, script) = session();
next.docs.clear();
assert_eq!(recover(&root, &mut next), 1);
assert!(script.lock().unwrap().joined.is_empty());
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_note_follows_its_copy_and_goes_when_the_file_leaves_its_session() {
let root = root("note");
let (mut s, _, _, _) = shared();
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let dir = root.join(std::process::id().to_string());
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 1));
// Left: the copy is an ordinary one from the next write on.
s.execute("collab.leave", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 0));
r.finish();
assert!(std::fs::read_dir(&root).unwrap().next().is_none());
let _ = std::fs::remove_dir_all(&root);
}
+288 -2
View File
@@ -4,7 +4,7 @@
use figcraft_engine::Session;
use serde_json::json;
use crate::recovery::{Recovery, recover};
use crate::recovery::{Recovery, recover, recover_all};
fn root(name: &str) -> std::path::PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
@@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() {
let ds = next.docs.first().unwrap();
assert!(ds.is_dirty() && ds.path.is_none());
assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1));
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
let _ = std::fs::remove_dir_all(&root);
}
@@ -62,3 +62,289 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
assert!(!dir.exists(), "a normal quit removes the copies");
let _ = std::fs::remove_dir_all(&root);
}
/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as
/// another process's).
fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf {
let mut r = Recovery::start(root).unwrap();
r.write(s, true);
drop(r);
let left = root.join("crashed");
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
left
}
fn copies_in(d: &std::path::Path) -> usize {
std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count())
}
/// The next start: the copies come back, and this run's recovery keeps them until it covers them.
fn restart(root: &std::path::Path) -> (Session, Recovery) {
let mut next = Session::empty();
let recovered = recover_all(root, &mut next);
assert_eq!(recovered.copies, 1);
let mut r = Recovery::start(root).unwrap();
r.adopt(recovered.waiting);
(next, r)
}
#[test]
fn a_recovered_copy_stays_until_this_run_has_its_own_copy() {
let root = root("handoff");
let left = crashed(&edited(), &root);
let (next, mut r) = restart(&root);
assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one");
r.write(&next, true);
r.tick(&next, 100.0);
assert!(!left.exists(), "covered by this run's copy, the old one goes");
assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1);
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recovered_copy_stays_while_its_replacement_cant_be_written() {
let root = root("blocked");
let left = crashed(&edited(), &root);
let (next, mut r) = restart(&root);
// Something is in the way of this run's copy: writing it fails.
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid));
std::fs::create_dir_all(&mine).unwrap();
r.write(&next, true);
r.tick(&next, 100.0);
assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk");
// Once it can be written, the old copy goes.
std::fs::remove_dir(&mine).unwrap();
let mut next = next;
next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap();
r.write(&next, true);
r.tick(&next, 200.0);
assert!(!left.exists());
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() {
let root = root("nocopies");
crashed(&edited(), &root);
let mut next = Session::empty();
let recovered = recover_all(&root, &mut next);
assert_eq!(recovered.copies, 1);
// This run couldn't take a folder of its own: it ends (or crashes) with nothing copied.
drop(recovered);
let mut after = Session::empty();
assert_eq!(recover(&root, &mut after), 1, "the work comes back again");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() {
for close in [false, true] {
let root = root(if close { "closed" } else { "saved" });
let left = crashed(&edited(), &root);
let (mut next, mut r) = restart(&root);
if close {
next.docs.clear();
} else {
next.ds_mut().unwrap().mark_saved();
}
r.tick(&next, 0.0);
assert!(!left.exists(), "close {close}");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let root = root("quitafter");
let left = crashed(&edited(), &root);
let (_, r) = restart(&root);
// Quitting asked to save or discard every document.
r.finish();
assert!(!left.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
// A crashed run with this process's id whose folder the start still holds (its copy waits).
let held = root.join(std::process::id().to_string());
std::fs::create_dir_all(&held).unwrap();
let lock = std::fs::File::create(held.join("lock")).unwrap();
lock.try_lock().unwrap();
let mut r = Recovery::start(&root).expect("a folder of its own");
let s = edited();
r.write(&s, true);
assert_eq!(copies_in(&held), 0);
let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count();
assert_eq!(dirs, 1);
r.finish();
drop(lock);
let _ = std::fs::remove_dir_all(&root);
}
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
}
/// The documents a crashed run's copies under `root` open as.
fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session {
drop(r);
let mut next = Session::empty();
assert_eq!(recover(root, &mut next), 1);
next
}
#[test]
fn copies_keep_the_version_history_and_branches() {
// #185: a copy has what a save would write, the latest edits on a branch tab included.
let root = root("history");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let next = crash_and_recover(r, &root);
let ds = next.docs.first().unwrap();
assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::<Vec<_>>(), [("Explore", 2)]);
let _ = std::fs::remove_dir_all(&root);
}
/// The one copy under `root` (the run's folder), read back.
fn copy(root: &std::path::Path) -> figcraft_format::Loaded {
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap();
figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap()
}
#[test]
fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() {
let root = root("history-only");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
for d in &mut s.docs {
d.mark_saved();
}
let mut r = Recovery::start(&root).unwrap();
// An edit on the branch tab only: the main file has no changes of its own yet.
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [2]);
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [3]);
// A version renamed on the main file leaves its document as it was.
s.execute("doc.switch", &json!({"index": 0})).unwrap();
r.write(&s, true);
let id = s.docs[0].versions[0].id.clone();
s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1 (renamed)"]);
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle<bool> {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
std::fs::write(path, b"stale").is_ok()
})
}
#[test]
fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() {
let root = root("slow-cleanup");
let mut s = edited();
let mut r = Recovery::start(&root).unwrap();
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 1, slow_write(copy.clone()));
s.ds_mut().unwrap().mark_saved();
r.write(&s, false);
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(!copy.exists(), "a saved document's copy stays removed");
// Closed: the same.
let s = edited();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 1, slow_write(copy.clone()));
r.write(&Session::empty(), false);
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(!copy.exists(), "a closed document's copy stays removed");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() {
let root = root("slow-emergency");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 0, slow_write(copy.clone()));
r.write(&s, true);
assert!(std::fs::read(&copy).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write");
let _ = std::fs::remove_dir_all(&root);
}
/// The files named `*.figcraft` in the run folder under `root`.
fn copy_count(root: &std::path::Path) -> usize {
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()
}
/// A folder at the path of `s`'s first document's copy in the run folder under `root`.
fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf {
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid));
std::fs::create_dir(&blocker).unwrap();
blocker
}
#[test]
fn a_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
// A folder where the copy goes makes its write fail on every platform (renaming the run folder
// away fails on Windows, where the run's lock file keeps it open).
let blocker = blocked_copy(&root, &s);
r.write(&s, true);
std::fs::remove_dir(&blocker).unwrap();
// Nothing was edited since: the same revision still has to be copied.
r.write(&s, true);
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry-bg");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
let blocker = blocked_copy(&root, &s);
r.write(&s, false);
// Let the failing write end before the way is clear again.
std::thread::sleep(std::time::Duration::from_millis(300));
std::fs::remove_dir(&blocker).unwrap();
for _ in 0..50 {
r.write(&s, false);
if copy_count(&root) == 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
+127 -12
View File
@@ -1,11 +1,12 @@
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
use serde_json::{Value, json};
pub struct Desktop {
@@ -59,12 +60,16 @@ impl Desktop {
/// The system's preferred interface languages, most preferred first, comma-separated
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
/// locale variables elsewhere. The UI shows the first one it has a catalog for.
/// locale variables elsewhere. The UI shows the first language in the list that has a catalog.
/// Windows keeps only its first preferred language (and English when it has no catalog): that
/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish
/// system) must not decide the interface language.
fn system_language() -> Option<String> {
#[cfg(target_os = "windows")]
{
windows::System::UserProfile::GlobalizationPreferences::Languages()
.map(|l| l.into_iter().map(|s| s.to_string()).collect::<Vec<_>>().join(","))
.and_then(|l| l.GetAt(0))
.map(|s| s.to_string())
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
.ok()
}
@@ -131,6 +136,34 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
fn reveal(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
let mut command = {
let mut c = std::process::Command::new("open");
c.arg("-R").arg(path);
c
};
#[cfg(windows)]
let mut command = {
let mut arg = std::ffi::OsString::from("/select,");
arg.push(path);
let mut c = std::process::Command::new("explorer");
c.arg(arg);
c
};
#[cfg(not(any(target_os = "macos", windows)))]
let mut command = {
let mut c = std::process::Command::new("xdg-open");
c.arg(path.parent().unwrap_or(path));
c
};
// The file manager keeps running; reap it off this thread so it leaves no zombie.
let mut child = command.spawn()?;
let _ = std::thread::Builder::new().name("figcraft-reveal".into()).spawn(move || child.wait());
Ok(())
}
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
@@ -141,26 +174,77 @@ pub fn save_state(app: &FigApp) {
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
/// either version whole).
fn write_prefs(dir: &Path, prefs: &Prefs) {
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
let tmp = dir.join("state.json.tmp");
let written = std::fs::create_dir_all(dir)
.and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string()))
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
if let Err(e) = written {
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
}
}
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
/// file from a crash is narrowed too: the mode given at open only applies to new files).
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o.open(path)?;
#[cfg(unix)]
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
f.write_all(bytes)
}
thread_local! {
/// While the main window draws: a dialog parented to it, so a dialog opened from it stays in
/// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent.
static PARENT: RefCell<Option<rfd::AsyncFileDialog>> = const { RefCell::new(None) };
}
/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window.
pub fn parented<R>(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R {
PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame))));
let r = f();
PARENT.with(|p| p.replace(None));
r
}
/// A dialog with `title`, parented to the main window while it draws.
fn dialog(title: &str) -> rfd::AsyncFileDialog {
PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title)
}
/// The dialog opens now; its future gives the path picked.
fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> DialogFuture {
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
/// Native Save as filters; other callers keep their existing combined filter.
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
if exts == ["figcraft", "fig"] {
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
} else {
vec![("File", exts)]
}
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file()
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
let mut dialog = dialog(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
}
fn pick_folder(&mut self, title: &str) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).pick_folder()
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
@@ -170,6 +254,9 @@ impl Services for Desktop {
fn clipboard_get_text(&mut self) -> Option<String> {
self.clipboard.as_mut()?.get_text().ok()
}
fn clipboard_get_html(&mut self) -> Option<String> {
self.clipboard.as_mut()?.get().html().ok()
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
if let Some(c) = self.clipboard.as_mut() {
let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) });
@@ -187,6 +274,11 @@ impl Services for Desktop {
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
fn reveal_path(&mut self, path: &Path) {
if let Err(e) = reveal(path) {
log::warn!("show in folder: {}: {e}", path.display());
}
}
fn save_prefs(&mut self, prefs: &Prefs) {
if let Some(dir) = &self.dir {
write_prefs(dir, prefs);
@@ -195,6 +287,8 @@ impl Services for Desktop {
fn emergency_save(&mut self, session: &Session) {
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
r.write(session, true);
// Earlier runs' copies now covered go, so the next start doesn't open them twice.
r.settle(session);
}
}
fn reduce_motion(&self) -> bool {
@@ -219,3 +313,24 @@ pub fn window_icon() -> Option<egui::IconData> {
let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
}
#[cfg(test)]
mod save_filter_tests {
use super::save_filters;
#[test]
fn native_and_fig_save_filters_are_separate_with_native_first() {
let extensions = ["figcraft", "fig"];
assert_eq!(
save_filters(&extensions),
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
);
}
#[test]
fn other_save_filters_keep_one_combined_group() {
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
}
}
}
+16
View File
@@ -66,3 +66,19 @@ fn plugins_are_kept_when_the_app_is_killed() {
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn preferences_are_readable_by_their_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = root("private");
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
// An older world-readable file and a leftover temporary one from a crash.
for name in ["state.json", "state.json.tmp"] {
std::fs::write(dir.join(name), "{}").unwrap();
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
}
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
+27
View File
@@ -0,0 +1,27 @@
//! When the window can't open.
//!
//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable
//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees
//! and the app quit without a word (#261). Now it is logged and shown in a message box.
/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would
/// keep the caller waiting, and it reads standard error), show it in a message box.
pub fn failed(error: &eframe::Error, quiet: bool) {
// In the interface language: the text and its catalog entries are the UI crate's.
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some());
// Standard error even when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{text}");
} else {
eprintln!("figcraft: {text}");
}
if quiet {
return;
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("FigCraft")
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
+35
View File
@@ -0,0 +1,35 @@
//! The message shown when the window can't open: it carries the error, a next step and where to
//! report it, instead of the app quitting silently (#261).
use figcraft_ui_egui::dialogs::window_failed as message;
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
let text = message(ERROR, false);
assert!(text.contains(ERROR), "the error itself: {text}");
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
}
#[test]
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
let text = message(ERROR, false);
assert!(text.contains("graphics driver"), "{text}");
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains("unsetting"), "{text}");
}
#[cfg(windows)]
#[test]
fn on_windows_it_shows_how_to_start_with_opengl() {
let text = message(ERROR, false);
assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}");
}
#[test]
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
let text = message(ERROR, true);
assert!(text.contains("unsetting"), "{text}");
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
}
+28
View File
@@ -16,3 +16,31 @@
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.VanTan666]
display_name = "VanTan"
[people.tylerjenningsw]
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.yyamandevrim]
real_name = "Yaman Devrim"
sync_github_name = true
[people.justinjohn0306]
real_name = "Justin John"
[people.Losminek]
[people.RevDonz]
[people.Nael-Nathanael]
sync_github_name = true
[people.volkovskey]
real_name = "Andrii Volkov"
+53
View File
@@ -0,0 +1,53 @@
//! Addresses removed from a file are kept only while its session lasts, so a long-running server
//! doesn't collect them for every file it ever had open.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use figcraft_collab::{Limits, Server};
use figcraft_doc::Document;
use super::{Admission, Backend, Closing, Hub, InFlight, Room};
/// A backend that closes a file as soon as everyone left it.
struct Closes;
impl Backend for Closes {
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
Err("no".into())
}
fn open(&mut self, _file: &str) -> Result<Server, String> {
Err("no".into())
}
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
true
}
fn closing(&self) -> Closing {
Closing::Restarting
}
}
#[test]
fn a_closed_file_forgets_whom_it_barred() {
let mut doc = Document::new("File");
doc.repair();
let mut hub = Hub {
in_flight: InFlight(Default::default()),
backend: Closes,
rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]),
conns: HashMap::new(),
local: None,
outbox: Vec::new(),
barred: HashMap::new(),
};
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7));
hub.barred.entry("f".into()).or_default().insert(ip);
hub.barred.entry("other".into()).or_default().insert(ip);
hub.close_if_empty("f");
assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed");
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
assert!(hub.barred.contains_key("other"), "another file's stay");
}
+318
View File
@@ -0,0 +1,318 @@
//! Join codes over the network: a file shared on the local network joined by its code (two
//! engine sessions over loopback), what the browser's driver does with an online code, a server
//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out
//! and resolving them is tested in its own crate, with the same link.)
use std::net::{Ipv4Addr, TcpListener};
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::code::{self, Code, Local};
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, ServerMsg};
use figcraft_engine::Session;
use figcraft_engine::live::{Event, Status};
use serde_json::json;
use tungstenite::{Bytes, Message};
use crate::driver::{Driver, Socket};
use crate::guest::GuestConfig;
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
use crate::loopback_tests::{fast_limits, quick};
use crate::{NetLink, manage};
fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump the sessions (as their windows would every frame) until `f` holds.
fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
let start = Instant::now();
loop {
for s in all.iter_mut() {
s.live_poll();
s.live_sync();
}
if f(all) {
return;
}
let states: Vec<String> = all.iter().map(|s| s.live_summary().to_string()).collect();
assert!(start.elapsed() < Duration::from_secs(15), "timed out waiting for {what}: {states:#?}");
std::thread::sleep(Duration::from_millis(5));
}
}
fn live(s: &Session) -> bool {
s.live().status == Status::Live && s.live().syncing()
}
fn layers(s: &Session) -> usize {
s.doc().map_or(0, |d| d.node_count())
}
#[test]
fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let (mut host, mut guest) = (session("Hana"), session("Ada"));
// This machine only, so the test needs no network; the code says where and the secret.
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
assert_eq!((&shared["status"], &shared["share"]["via"], &shared["share"]["access"]), (&json!("live"), &json!("local"), &json!("edit")));
let shown = shared["code"].as_str().unwrap().to_string();
assert_eq!(shown.len(), 23, "twenty symbols in fives: {shown}");
let Code::Local(local) = code::parse(&shown).unwrap() else { panic!("not a local code") };
assert_eq!(local.ip, Ipv4Addr::LOCALHOST);
assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port));
assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session");
assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network");
// Typed the way people type: lower case, spaces for the dashes.
let typed = shown.to_lowercase().replace('-', " ");
assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting");
pump(&mut [&mut host, &mut guest], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(guest.live_summary()["access"], "edit");
// Edits go both ways.
let before = layers(&host);
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
pump(&mut [&mut host, &mut guest], "the host's edit", |s| layers(s[1]) == before + 1);
guest.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap();
pump(&mut [&mut host, &mut guest], "the guest's edit", |s| layers(s[0]) == before + 2);
// The link joins the same session (an older app, or a paste).
let mut third = session("Bo");
third.execute("collab.join", &json!({"link": shared["link"]})).unwrap();
pump(&mut [&mut host, &mut guest, &mut third], "the link's join", |s| live(s[2]) && layers(s[2]) == before + 2);
// Stopping ends it: the code no longer joins.
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
pump(&mut [&mut host, &mut guest, &mut third], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
let mut late = session("Cy");
late.execute("collab.join", &json!({"code": shown})).unwrap();
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
}
#[test]
fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
let (mut host, mut guest) = (session("Hana"), session("Ada"));
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
// A code for the same address with another secret: well formed, refused by the host.
let guess = Local::new(local.ip, local.port, local.secret ^ 1).code();
guest.execute("collab.join", &json!({"code": guess})).unwrap();
pump(&mut [&mut host, &mut guest], "the refusal", |s| matches!(s[1].live().status, Status::Failed { .. }));
assert!(guest.live().status.label().contains("wrong session token"), "{:?}", guest.live().status);
assert!(host.live().peers.is_empty());
// A typing mistake is caught before anything connects.
let mut typo: Vec<char> = local.code().chars().collect();
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
assert!(e.contains("typing mistake"), "{e}");
// A view-only code doesn't exist here, and says where it does.
let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string();
assert!(e.contains("share it online"), "{e}");
}
#[test]
fn links_can_name_a_join_code() {
let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap();
assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", ""));
assert_eq!(found.as_deref(), Some("K7M2QX9TD"));
let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap();
assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD")));
// A link with a file and a token is read as before.
let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None));
for bad in [
"figcraft://join?server=wss://share.example/&code=",
"figcraft://join?server=wss://share.example/&code=K7M",
"figcraft://join?code=K7M2QX9TD",
] {
assert!(Invite::parse_join(bad).is_err(), "{bad}");
}
// Where a file's link is needed (team libraries), a code isn't one.
let e = Invite::parse("figcraft://join?server=wss://share.example/&code=K7M2QX9TD").unwrap_err();
assert!(e.contains("join code") && !e.contains("K7M"), "{e}");
}
/// A server from before join codes: whatever it is asked first, it can't read it (what its
/// decoder says about a message it doesn't know).
fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
let t = std::thread::spawn(move || {
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let _ = ws.read();
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
let _ = ws.close(None);
while ws.read().is_ok() {}
}
});
(at, t)
}
/// A server from before join codes that manages a file's links as it always did: two connections,
/// the first makes links, the second (asked for codes) can't read the request.
fn old_server_with_links() -> (Endpoint, std::thread::JoinHandle<()>) {
use figcraft_collab::message::{Admin, AdminReply, LinkInfo, NewLink};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
let t = std::thread::spawn(move || {
let mut made = 0;
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let say = |ws: &mut tungstenite::WebSocket<std::net::TcpStream>, m: ServerMsg| {
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&m).unwrap())));
};
while let Ok(Message::Binary(b)) = ws.read() {
match message::decode::<ClientMsg>(&b, usize::MAX) {
Ok(ClientMsg::OpenHosting(_)) => say(&mut ws, ServerMsg::Hosting(Box::default())),
Ok(ClientMsg::Admin(Admin::CreateLink { file, access, label })) => {
made += 1;
let link = LinkInfo { id: format!("link{made}"), file, access, label, created: 0 };
say(&mut ws, ServerMsg::Admin(Box::new(AdminReply::Link(NewLink { link, token: format!("TokenTokenTokenToken0{made}") }))));
}
_ => {
say(&mut ws, ServerMsg::Refused { reason: "that message couldn't be read".into() });
break;
}
}
}
let _ = ws.close(None);
while ws.read().is_ok() {}
}
});
(at, t)
}
#[test]
fn a_server_older_than_join_codes_says_so_and_the_links_still_work() {
let (server, thread) = old_server();
let limits = fast_limits();
// Asking it about a code: the join fails with what to do instead.
let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err();
assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}");
let mut guest = session("Ada");
guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap();
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. }));
let why = guest.live().status.label();
assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}");
thread.join().unwrap();
// Sharing a file there: it makes the links (it always could) but no codes, without an error:
// the app shows the links.
let (server, thread) = old_server_with_links();
let shared =
manage::share(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &[], true, &limits, Duration::from_secs(5)).unwrap().unwrap();
assert!(shared.edit.code.is_none() && shared.view.as_ref().is_some_and(|v| v.code.is_none()));
assert!(shared.edit.link.contains("&file=0123456789abcdef&token=TokenTokenTokenToken01") && shared.edit.id == "link1");
thread.join().unwrap();
}
#[test]
fn a_server_that_cant_be_reached_says_so() {
// A port nothing listens on.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let mut guest = session("Ada");
guest.execute("pref.publishServer", &json!({"url": format!("ws://127.0.0.1:{port}/")})).unwrap();
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
pump(&mut [&mut guest], "the failure", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(guest.live().status.label().starts_with("Couldn't reach the server"), "{:?}", guest.live().status);
}
#[test]
fn an_app_hosted_session_knows_no_online_codes() {
let mut host = session("Hana");
let shared = host.execute("collab.host", &json!({"port": 0})).unwrap();
let server = Endpoint::plain(shared["address"].as_str().unwrap());
let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("hosted by an app"), "{e}");
// Junk in place of a code is refused the same way, and the session goes on.
for junk in ["", "💥", &"Z".repeat(4000)] {
assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err());
}
assert_eq!(host.live_summary()["status"], "live");
}
/// A socket that records what the driver does (the browser's, scripted).
#[derive(Default)]
struct Script {
opened: Vec<String>,
sent: Vec<ClientMsg>,
closes: usize,
}
impl Socket for Script {
fn open(&mut self, url: &str) -> Result<(), String> {
self.opened.push(url.to_string());
Ok(())
}
fn send(&mut self, bytes: &[u8]) -> bool {
self.sent.push(message::decode(bytes, usize::MAX).unwrap());
true
}
fn close(&mut self) {
self.closes += 1;
}
}
/// The browser's driver joining with a code at a server.
fn driver(t0: web_time::Instant) -> (Driver<Script>, Inbox) {
let (invite, found) = Invite::parse_join("figcraft://join?server=ws://127.0.0.1:47900/&code=K7M2QX9TD").unwrap();
let inbox = Inbox::new(Arc::new(|| {}));
let cfg = GuestConfig { code: found, ..GuestConfig::new(invite, "Ada".into()) };
(Driver::start(Script::default(), cfg, inbox.clone(), t0), inbox)
}
fn failure(inbox: &Inbox) -> String {
inbox.drain().into_iter().find_map(|e| if let Event::Status(Status::Failed { reason }) = e { Some(reason) } else { None }).unwrap_or_default()
}
#[test]
fn the_browser_asks_about_the_code_then_joins_the_file() {
let t0 = web_time::Instant::now();
let (mut d, inbox) = driver(t0);
d.opened(t0);
let [ClientMsg::Resolve(ask)] = d.sock.sent.as_slice() else { panic!("asked {:?}", d.sock.sent) };
assert_eq!((ask.protocol, ask.code.as_str()), (PROTOCOL, "K7M2QX9TD"));
// The answer: a new connection to the same server, and a hello for the file.
let answer = ServerMsg::Resolved { file: "0123456789abcdef".into(), token: "AbCdEfGhIjKlMnOpQrStUv".into() };
d.message(&message::encode(&answer).unwrap(), t0);
assert_eq!((d.sock.opened.len(), d.sock.closes), (2, 1));
assert!(!d.done());
d.opened(t0);
let Some(ClientMsg::Hello(hello)) = d.sock.sent.last() else { panic!("sent {:?}", d.sock.sent) };
assert_eq!((hello.file.as_str(), hello.token.as_str(), hello.name.as_str()), ("0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", "Ada"));
assert!(failure(&inbox).is_empty());
}
#[test]
fn the_browser_shows_why_a_code_didnt_join() {
let t0 = web_time::Instant::now();
// The server's own words for a code it doesn't know.
let (mut d, inbox) = driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Refused { reason: "No session with that code".into() }).unwrap(), t0);
assert!(d.done());
assert_eq!(failure(&inbox), "No session with that code");
// An older server.
let (mut d, inbox) = driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Refused { reason: "that message couldn't be read".into() }).unwrap(), t0);
assert!(failure(&inbox).contains("doesn't know join codes yet"));
// Nobody answers: no retries while still asking.
let (mut d, inbox) = driver(t0);
d.tick(t0 + Duration::from_secs(30));
assert!(d.done());
assert!(failure(&inbox).starts_with("Couldn't reach the server"));
// An answer that isn't a file.
let (mut d, inbox) = driver(t0);
d.opened(t0);
d.message(&message::encode(&ServerMsg::Resolved { file: "../etc".into(), token: "short".into() }).unwrap(), t0);
assert!(d.done());
assert!(failure(&inbox).contains("isn't a file"));
// Leaving while asking is just leaving.
let (mut d, inbox) = driver(t0);
d.leave(t0);
assert!(d.done() && failure(&inbox).is_empty());
}
+23 -4
View File
@@ -5,14 +5,18 @@
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
//!
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
//! code (or can't be reached) ends the join with the reason.
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Presence, Resolve, ServerMsg};
use figcraft_engine::live::{Event, Status};
// `std::time::Instant` on native builds; the browser's clock in the web build.
use web_time::Instant;
use crate::inbox::Inbox;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
@@ -93,12 +97,15 @@ impl<S: Socket> Driver<S> {
}
}
/// The connection is open: say hello.
/// The connection is open: say hello (or ask about the join code first).
pub(crate) fn opened(&mut self, now: Instant) {
let Some(c) = self.conn.as_mut() else { return };
c.opened = true;
c.last_heard = now;
let hello = hello(&self.cfg, &self.resume);
let hello = match &self.cfg.code {
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
None => hello(&self.cfg, &self.resume),
};
if !self.write(&hello, now) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -109,6 +116,16 @@ impl<S: Socket> Driver<S> {
let Some(c) = self.conn.as_mut() else { return };
c.last_heard = now;
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
// The join code's answer: join the file it opens, on a new connection.
Ok(ServerMsg::Resolved { file, token }) if self.cfg.code.is_some() => {
return match resolved(&mut self.cfg, file, token) {
Ok(()) => {
self.sock.close();
self.connect(now);
}
Err(o) => self.end(o, now),
};
}
// Single-threaded: the connection counts as welcomed below, before the engine polls.
Ok(m) => take(m, &self.cfg, &self.inbox, &mut c.seen, &self.resume, &|| {}),
Err(e) => Taken::End(Outcome::Refused(format!("the host sent a message this version can't read ({e})"))),
@@ -233,6 +250,8 @@ impl<S: Socket> Driver<S> {
/// The connection ended: close it, then stop or plan the reconnect.
fn end(&mut self, outcome: Outcome, now: Instant) {
// Still asking about the join code: whatever went wrong is final.
let outcome = if self.cfg.code.is_some() { unresolved(&self.cfg, outcome) } else { outcome };
if self.conn.take().is_some() {
self.sock.close();
}
+34 -4
View File
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@@ -39,6 +39,8 @@ pub struct Publish {
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
pub share: bool,
}
/// A joined (or joining) session. Dropping it leaves.
@@ -60,10 +62,27 @@ enum Cmd {
impl Guest {
/// Start joining (returns at once; progress arrives in `inbox`).
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
pub fn start(mut cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
let (tx, rx) = channel();
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || supervise(&cfg, &inbox, &rx, &me))?;
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || {
// A join code: the server says which file it opens, then the join goes on as with a link.
if let Some(code) = cfg.code.clone() {
inbox.push(Event::Status(Status::Connecting));
let asked =
manage::resolve(&cfg.invite.server, &code, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
Some(why) => Outcome::Refused(why.to_string()),
None => Outcome::Retry(e),
});
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
inbox.push(e);
}
return;
}
}
supervise(&cfg, &inbox, &rx, &me);
})?;
Ok(Guest { cmd: tx, thread: Some(thread) })
}
@@ -79,10 +98,21 @@ impl Guest {
Ok(done) => done,
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
};
// Before anyone hears the file is there, so what others join with shows with it. The
// file's first links stay its owner's (this app joins with one): others get their own,
// which stopping takes away again.
let shared = job.share.then(|| {
let owner = published.owner.as_deref().unwrap_or_default();
let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout);
made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))
});
if let Some(h) = hosting {
inbox.push(Event::Hosting(h));
}
inbox.push(Event::Published(published));
if let Some(shared) = shared {
inbox.push(Event::Shared(shared));
}
// Left while uploading: the file stays on the server, nobody joins it. Anything else
// that came meanwhile (presence) waits for the session.
let mut waiting = Vec::new();
@@ -191,7 +221,7 @@ fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
}
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
let resume: Arc<Resuming> = Arc::default();
let resume: Arc<Resuming> = Arc::new(std::sync::Mutex::new(cfg.resume));
let mut attempt = 0u32;
let mut was_live = false;
let mut pending: Option<Presence> = None;
+7
View File
@@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, PEOPLE};
pub use crate::hub::REMOVED;
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
use crate::inbox::Inbox;
pub use crate::join::ENDED;
@@ -129,6 +130,12 @@ impl Host {
self.hosting.send(msg);
}
/// Remove client `id` from the session: they are disconnected and can't join again from
/// their address while it runs. The host can't remove themselves.
pub fn remove(&self, id: u32) {
self.hosting.remove(id);
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
+142 -15
View File
@@ -21,7 +21,7 @@
//! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is
//! answered `200 ok` for uptime checks.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{self, Read, Write};
use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -32,7 +32,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{
self, Access, Admin, AdminReply, ClientMsg, Hello, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Presence, ServerMsg, reads_hosting,
PluginReply, PluginReq, Presence, Resolve, ServerMsg, reads_hosting,
};
use figcraft_collab::{Outgoing, Server};
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
@@ -205,6 +205,12 @@ pub trait Backend: Send + 'static {
Err("this session is hosted by an app: tell the person hosting it".into())
}
/// A join code typed by someone at `ip`: the file it opens and that link's token, or why not
/// (shown to them; wrong codes are counted against the address).
fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> {
Err("this session is hosted by an app: join it with its own code or invite link".into())
}
/// What everyone is told when the hub stops.
fn closing(&self) -> Closing;
}
@@ -386,8 +392,8 @@ impl InFlight {
}
enum HubIn {
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins` or
/// `OpenHosting`) and its client's address ([`ip_key`]).
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`,
/// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]).
Open {
conn: u64,
ip: IpAddr,
@@ -407,6 +413,8 @@ enum HubIn {
},
Local(Presence),
LocalMsg(ClientMsg),
/// The person hosting removes client `id` from their file (app sessions).
Remove(u32),
/// Cut every connection without a word (tests: a network failure).
#[cfg(test)]
Cut,
@@ -455,7 +463,15 @@ impl Hosting {
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
let addr = listener.local_addr()?;
let in_flight = InFlight(Arc::default());
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() };
let mut hub = Hub {
backend,
rooms: HashMap::new(),
conns: HashMap::new(),
local: None,
outbox: Vec::new(),
in_flight: in_flight.clone(),
barred: HashMap::new(),
};
if let Some(l) = local {
let mut room = Room::new(l.server);
room.local = Some((l.id, l.inbox));
@@ -545,6 +561,12 @@ impl Hosting {
let _ = self.hub.send(HubIn::LocalMsg(msg));
}
/// The hosting person removes client `id` from their file (app sessions; see
/// [`HubIn::Remove`]).
pub(crate) fn remove(&self, id: u32) {
let _ = self.hub.send(HubIn::Remove(id));
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
@@ -907,7 +929,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
| ClientMsg::Manage(_)
| ClientMsg::OpenLibrary(_)
| ClientMsg::OpenPlugins(_)
| ClientMsg::OpenHosting(_)),
| ClientMsg::OpenHosting(_)
| ClientMsg::Resolve(_)),
bytes,
))) => {
// Sessions and the operator may use the last connections; one-shot requests
@@ -1097,6 +1120,20 @@ fn unsupported(protocol: u32) -> String {
format!("protocol {protocol} isn't supported (this server speaks {PROTOCOL})")
}
/// What someone in a file is told when the link they joined with is revoked (its owner stopped
/// sharing): the session's end for them.
pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open";
/// What someone removed from a file is told: the session's end for them (they keep their copy;
/// nothing retries).
pub const REMOVED: &str = "You were removed from this session. Your copy stays open";
/// Why a removed person's address can't join the file again.
const BARRED: &str = "you were removed from this session";
/// The most addresses one file keeps barred.
const MAX_BARRED: usize = 256;
/// What everyone in a file is told when its changes couldn't be saved (they reconnect).
const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting";
@@ -1110,6 +1147,9 @@ struct Hub<B> {
local: Option<String>,
/// What the servers said, sent once the backend made what changed durable.
outbox: Vec<(String, Outgoing)>,
/// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its
/// session closes (the file is closed or removed; the entry goes with it).
barred: HashMap<String, HashSet<IpAddr>>,
}
impl<B: Backend> Hub<B> {
@@ -1171,6 +1211,7 @@ impl<B: Backend> Hub<B> {
ClientMsg::OpenLibrary(o) => self.open_library(conn, o, c),
ClientMsg::OpenPlugins(o) => self.open_plugins(conn, &o, c),
ClientMsg::OpenHosting(o) => self.open_hosting(conn, &o, c),
ClientMsg::Resolve(r) => self.resolve(&r, &c),
_ => refuse(&c.out, "say hello first"),
}
}
@@ -1196,6 +1237,13 @@ impl<B: Backend> Hub<B> {
self.client_message(&file, id, msg, None);
}
}
HubIn::Remove(id) => {
if let Some(file) = self.local.clone()
&& let Err(why) = self.remove_peer(&file, id)
{
log::info!("live session: couldn't remove client {id}: {why}");
}
}
#[cfg(test)]
HubIn::Cut => {
for c in self.conns.values() {
@@ -1220,6 +1268,14 @@ impl<B: Backend> Hub<B> {
self.client_message(&file, id, msg, Some(bytes));
}
Some(Kind::Hosting { owns }) => match msg {
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = if owns.as_deref() == Some(file.as_str()) {
self.removed_reply(&file, client)
} else {
AdminReply::Failed { reason: "an owner token manages only its own file".into() }
};
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
}
ClientMsg::Admin(req) => {
let owns = owns.clone();
let rooms = &self.rooms;
@@ -1231,6 +1287,10 @@ impl<B: Backend> Hub<B> {
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
},
Some(Kind::Managing) => match msg {
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = self.removed_reply(&file, client);
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
}
ClientMsg::Admin(req) => self.admin(conn, req),
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
@@ -1293,6 +1353,10 @@ impl<B: Backend> Hub<B> {
Ok(a) => a,
Err(why) => return refuse(&c.out, &why),
};
if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) {
log::info!("event=refused ip={} reason=removed", c.ip);
return refuse(&c.out, BARRED);
}
let hosting = reads_hosting(&hello.client_version);
// The backend checked the secret: the sessions don't keep it.
hello.token.clear();
@@ -1350,6 +1414,34 @@ impl<B: Backend> Hub<B> {
}
}
/// Remove client `id` from `file`: they are told the session is over for them and
/// disconnected, and their address can't join the file again while its session lasts. The
/// person hosting can't be removed.
fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> {
let room = self.rooms.get(file).ok_or("nobody is in this file")?;
if room.local.as_ref().is_some_and(|(local, _)| *local == id) {
return Err("the person hosting can't be removed".into());
}
let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
let barred = self.barred.entry(file.to_string()).or_default();
if barred.len() < MAX_BARRED {
barred.insert(c.ip);
}
log::info!("event=removed file={file} client={id} ip={}", c.ip);
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
self.gone(conn);
Ok(())
}
/// [`remove_peer`](Self::remove_peer) as a managing connection's answer.
fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply {
match self.remove_peer(file, id) {
Ok(()) => AdminReply::Done,
Err(reason) => AdminReply::Failed { reason },
}
}
/// Take in connection `c` as `kind` and send it `first`.
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
@@ -1408,6 +1500,26 @@ impl<B: Backend> Hub<B> {
self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info)));
}
/// A join code: the file it opens and the token to join it with, then the connection closes
/// (it was never taken in).
fn resolve(&mut self, r: &Resolve, c: &Pending) {
if r.protocol != PROTOCOL {
return refuse(&c.out, &unsupported(r.protocol));
}
match self.backend.resolve(c.ip, &r.code) {
Ok((file, token)) => {
if let Some(b) = encode(&ServerMsg::Resolved { file, token }) {
let _ = c.out.tx.try_send(Out::Bytes(b, None));
}
let _ = c.out.tx.try_send(Out::Close(CloseCode::Normal, String::new()));
}
Err(why) => {
log::info!("event=code_refused ip={} reason={why:?}", c.ip);
refuse(&c.out, &why);
}
}
}
fn admin(&mut self, conn: u64, req: Admin) {
let rooms = &self.rooms;
let open = |file: &str| rooms.get(file).map(Room::info);
@@ -1419,15 +1531,24 @@ impl<B: Backend> Hub<B> {
fn handled(&mut self, conn: u64, handled: Handled) {
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
if let Some((file, link)) = handled.revoked {
let users: Vec<u64> = self
.conns
.iter()
.filter(|(_, c)| {
matches!(&c.kind, Kind::Joined { file: f, link: Some(l), .. } | Kind::Library { file: f, link: Some(l), .. } if *f == file && *l == link)
})
.map(|(k, _)| *k)
.collect();
self.kick(&users, "the link you joined with was revoked");
let with = |joined: bool| -> Vec<u64> {
let uses = |c: &Conn| match &c.kind {
Kind::Joined { file: f, link: Some(l), .. } => joined && *f == file && *l == link,
Kind::Library { file: f, link: Some(l), .. } => !joined && *f == file && *l == link,
_ => false,
};
self.conns.iter().filter(|(_, c)| uses(c)).map(|(k, _)| *k).collect()
};
let (people, libraries) = (with(true), with(false));
// People in the file are told the session is over for them, as when a host stops (they
// keep their copy; nothing retries); a library request is refused.
for conn in &people {
if let Some(c) = self.conns.get(conn) {
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into()));
}
self.gone(*conn);
}
self.kick(&libraries, "the link you joined with was revoked");
}
if let Some(file) = handled.removed {
let users: Vec<u64> = self
@@ -1438,6 +1559,7 @@ impl<B: Backend> Hub<B> {
.collect();
// Closed without saving: the file is gone.
self.rooms.remove(&file);
self.barred.remove(&file);
self.kick(&users, "this file was removed from the server");
}
if let Some(ip) = handled.blocked {
@@ -1515,6 +1637,7 @@ impl<B: Backend> Hub<B> {
let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return };
if self.backend.idle(file, &mut room.server) {
self.rooms.remove(file);
self.barred.remove(file);
}
}
@@ -1620,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) {
#[cfg(test)]
#[path = "queue_tests.rs"]
mod queue_tests;
#[cfg(test)]
#[path = "barred_tests.rs"]
mod barred_tests;
+19 -1
View File
@@ -10,6 +10,10 @@
//! connects to the endpoint and sends the token in its `Hello`. A server address for managing
//! files carries the server's key the same way (`ws://host:port/?key=<key>`). Errors never echo a
//! link or an address.
//!
//! A join by code names the server to ask and the code:
//! `figcraft://join?server=<ws(s)://…>&code=<code>` ([`Invite::parse_join`]); the server says which
//! file and token the code stands for.
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -134,6 +138,15 @@ impl Invite {
/// Read an invite link (any form; surrounding spaces, quotes or angle brackets are fine).
pub fn parse(link: &str) -> Result<Invite, String> {
match Self::parse_join(link)? {
(invite, None) => Ok(invite),
(_, Some(_)) => Err("that names a join code, not a file: join with it first".into()),
}
}
/// Read what a join starts from: an invite link, or a server with a join code to ask it about
/// (then the invite names only the server, until the server says what the code opens).
pub fn parse_join(link: &str) -> Result<(Invite, Option<String>), String> {
let link = clean(link);
if link.len() > MAX_LINK {
return Err(NOT.into());
@@ -158,9 +171,14 @@ impl Invite {
return Err(NOT.into());
};
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
if let Some(code) = get("code") {
let code = figcraft_collab::code::normalize(&code).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN);
let code = code.ok_or("the link's join code isn't one")?;
return Ok((Invite { server, file: String::new(), token: String::new() }, Some(code)));
}
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
Ok(Invite { server, file, token })
Ok((Invite { server, file, token }, None))
}
}
+37 -2
View File
@@ -29,6 +29,9 @@ pub(crate) const CLOSE_RESTART: u16 = 1012;
#[derive(Clone, Debug)]
pub struct GuestConfig {
pub invite: Invite,
/// A join code to ask `invite.server` about first: its answer fills in the invite's file and
/// token ([`resolved`]).
pub code: Option<String>,
/// Your name (empty: the host picks one).
pub name: String,
pub limits: NetLimits,
@@ -38,18 +41,23 @@ pub struct GuestConfig {
pub max_backoff: Duration,
/// Reconnect attempts before giving up.
pub attempts: u32,
/// Join as the client you were (an app that starts over after a crash): the first hello
/// carries it, and a server that still knows the id gives it back.
pub resume: Option<Resume>,
}
impl GuestConfig {
pub fn new(invite: Invite, name: String) -> Self {
Self {
invite,
code: None,
name,
limits: NetLimits::default(),
connect_timeout: Duration::from_secs(5),
backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(10),
attempts: 12,
resume: None,
}
}
@@ -67,10 +75,36 @@ pub(crate) enum Outcome {
Refused(String),
/// The host ended the session: final.
Ended(String),
/// It can't go on, for this reason as it is shown: final.
Failed(String),
/// You left.
Left,
}
/// The server couldn't be asked about a join code, or said no: final, in words for the person who
/// typed it (the server's own for a code it doesn't know).
pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
Outcome::Failed(match outcome {
// A server from before join codes can't read the question.
Outcome::Refused(why) if why.contains("couldn't be read") => {
format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
}
Outcome::Refused(why) | Outcome::Failed(why) | Outcome::Ended(why) => why,
Outcome::Retry(why) => format!("Couldn't reach the server ({why})"),
Outcome::Left => return Outcome::Left,
})
}
/// The server's answer to the join code: the invite is whole now.
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into()));
}
cfg.code = None;
(cfg.invite.file, cfg.invite.token) = (file, token);
Ok(())
}
/// What a close frame from the host means.
pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
match code {
@@ -97,6 +131,7 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
match outcome {
Outcome::Left => Next::Stop(None),
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
Outcome::Failed(why) => failed(why),
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
Outcome::Retry(why) => {
@@ -211,8 +246,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)),
ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)),
ServerMsg::Reported { error } => inbox.push(Event::Reported(error)),
// Answers for managing and library connections: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {}
// Answers for managing and library connections, and to a join code: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {}
}
Taken::Continue
}
+5
View File
@@ -19,6 +19,7 @@ pub mod inbox;
pub mod invite;
mod join;
pub mod limits;
mod sessions;
pub mod throttle;
#[cfg(not(target_arch = "wasm32"))]
@@ -51,6 +52,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip};
#[cfg(target_arch = "wasm32")]
pub use web::NetLink;
#[cfg(test)]
mod code_tests;
#[cfg(test)]
mod driver_tests;
#[cfg(test)]
@@ -62,4 +65,6 @@ mod loopback_tests;
#[cfg(test)]
mod session_tests;
#[cfg(test)]
mod sessions_tests;
#[cfg(test)]
mod tls_tests;
+92 -46
View File
@@ -5,9 +5,11 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use figcraft_collab::code;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_engine::live::{
Bind, Event, HostInfo, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
SessionId, SessionInfo, Shared,
};
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
@@ -16,6 +18,7 @@ use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::manage;
use crate::net::NetLimits;
use crate::sessions::Sessions;
use crate::token;
/// The port a hosted session tries first.
@@ -29,12 +32,20 @@ enum Active {
Guest(Guest),
}
/// Hosts or joins one session at a time.
impl Active {
/// Stop hosting, or leave.
fn end(self) {
match self {
Active::Host(mut h) => h.stop(),
Active::Guest(mut g) => g.leave(),
}
}
}
/// Hosts and joins sessions, any number at once (one per open document).
pub struct NetLink {
wake: Waker,
limits: NetLimits,
active: Option<Active>,
inbox: Option<Inbox>,
sessions: Sessions<Active>,
/// Tweaks for joining (tests shorten the reconnect pauses).
guest: fn(GuestConfig) -> GuestConfig,
/// Answers to requests sent in the background (outlives sessions).
@@ -47,7 +58,7 @@ impl NetLink {
/// `wake` is called (from network threads) whenever something arrives for the UI.
pub fn new(wake: Waker) -> Self {
let libraries = Inbox::new(wake.clone());
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c, libraries, fetching: Arc::default() }
Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default() }
}
/// With other transport limits.
@@ -62,20 +73,27 @@ impl NetLink {
self
}
/// How this link joins `invite`.
fn guest_config(&self, invite: Invite, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
/// How this link joins `invite` (after asking its server about `code`, if there is one).
fn guest_config(&self, invite: Invite, code: Option<String>, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) });
cfg.limits = self.limits;
cfg
}
/// The running host, if hosting.
pub fn host_handle(&self) -> Option<&Host> {
match &self.active {
/// The running host of session `session`, if it is hosted here.
pub fn host_handle(&self, session: SessionId) -> Option<&Host> {
match self.sessions.get(session) {
Some(Active::Host(h)) => Some(h),
_ => None,
}
}
/// Session `session` started (one that had the id ends).
fn started(&mut self, session: SessionId, active: Active, inbox: Inbox) {
if let Some(old) = self.sessions.insert(session, active, inbox) {
old.end();
}
}
}
/// This machine's address on the local network: the interface the default route leaves by (no
@@ -96,15 +114,20 @@ pub fn lan_ip() -> Option<IpAddr> {
}
impl LiveLink for NetLink {
fn host(&mut self, o: HostOptions) -> Result<SessionInfo, String> {
self.leave();
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
let ip = match o.bind {
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
Bind::Lan => lan_ip().ok_or("this machine has no local network address (connect to a network, or share on this machine only)")?,
Bind::Addr(ip) => ip,
};
let (file, token) = (token::file_id()?, token::secret()?);
let inbox = Inbox::new(self.wake.clone());
// A session shared with a join code: its secret is short enough to be typed with the
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
let (file, token) = if o.short {
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
} else {
(token::file_id()?, token::secret()?)
};
let inbox = self.sessions.inbox();
let doc = (*o.doc).clone();
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
let file_name = doc.name.clone();
@@ -133,34 +156,33 @@ impl LiveLink for NetLink {
network: !ip.is_loopback(),
access: Access::Edit,
};
self.inbox = Some(inbox);
self.active = Some(Active::Host(host));
self.started(session, Active::Host(host), inbox);
Ok(info)
}
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
let cfg = self.guest_config(invite, name);
self.leave();
let inbox = Inbox::new(self.wake.clone());
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
self.join_resuming(session, link, name, None)
}
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, resume: Option<Resume>) -> Result<(), String> {
let (invite, code) = Invite::parse_join(link)?;
let cfg = GuestConfig { resume, ..self.guest_config(invite, code, name) };
let inbox = self.sessions.inbox();
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
self.started(session, Active::Guest(guest), inbox);
Ok(())
}
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
// Without a key: as anyone may, when the server is open to the public (it says if not).
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
self.leave();
let inbox = Inbox::new(self.wake.clone());
let job = Publish { server, key, name: o.name, doc: o.doc };
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
let inbox = self.sessions.inbox();
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
self.started(session, Active::Guest(guest), inbox);
Ok(())
}
@@ -180,6 +202,10 @@ impl LiveLink for NetLink {
hosting(server, file, owner, req, &self.limits)
}
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
share(server, file, owner, revoke, create, &self.limits)
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
let (inbox, limits) = (self.libraries.clone(), self.limits);
let (failed, failure) = (inbox.clone(), req.failure());
@@ -189,6 +215,7 @@ impl LiveLink for NetLink {
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)),
};
inbox.push(Event::Net { job, reply: Box::new(reply) });
});
@@ -216,34 +243,43 @@ impl LiveLink for NetLink {
}
}
fn leave(&mut self) {
match self.active.take() {
Some(Active::Host(mut h)) => h.stop(),
Some(Active::Guest(mut g)) => g.leave(),
None => {}
fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> {
match self.sessions.get(session) {
Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()),
Some(Active::Host(h)) => {
h.remove(client);
Ok(())
}
Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()),
None => Err("this session is over".into()),
}
self.inbox = None;
}
fn presence(&mut self, p: &Presence) {
match &self.active {
fn leave(&mut self, session: SessionId) {
if let Some(active) = self.sessions.remove(session) {
active.end();
}
}
fn presence(&mut self, session: SessionId, p: &Presence) {
match self.sessions.get(session) {
Some(Active::Host(h)) => h.presence(p.clone()),
Some(Active::Guest(g)) => g.presence(p.clone()),
None => {}
}
}
fn send(&mut self, msg: ClientMsg) {
match &self.active {
fn send(&mut self, session: SessionId, msg: ClientMsg) {
match self.sessions.get(session) {
Some(Active::Host(h)) => h.send(msg),
Some(Active::Guest(g)) => g.send(msg),
None => {}
}
}
fn poll(&mut self) -> Vec<Event> {
let mut out = self.libraries.drain();
out.extend(self.inbox.as_ref().map(Inbox::drain).unwrap_or_default());
fn poll(&mut self) -> Vec<(SessionId, Event)> {
let mut out: Vec<(SessionId, Event)> = self.libraries.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
out.extend(self.sessions.poll());
out
}
}
@@ -271,6 +307,14 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &N
Ok((info, reply))
}
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
/// the file's owner.
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
let (endpoint, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
}
/// One request to the plugin directory at `server` (its key in the address's query, if any).
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
let (server, query) = Endpoint::parse(server)?;
@@ -280,6 +324,8 @@ fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginRep
impl Drop for NetLink {
fn drop(&mut self) {
self.leave();
for id in self.sessions.ids() {
self.leave(id);
}
}
}
+32 -1
View File
@@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
use crate::guest::{Guest, GuestConfig};
use crate::host::{ENDED, Host, HostConfig};
use crate::host::{ENDED, Host, HostConfig, REMOVED};
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::net::{Bucket, NetLimits};
@@ -611,3 +611,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
}
}
}
#[test]
fn the_host_removes_someone_and_they_cant_come_back() {
let (mut host, mut hp, invite) = start();
let (_a, mut ap) = join(&invite, "Ada");
let (b, mut bp) = join(&invite, "Bo");
ap.wait("welcome", welcomed);
bp.wait("welcome", welcomed);
let (ada, bo) = (ap.me(), bp.me());
connections_settle(&host, 2);
// The host can't remove themselves, nor someone who isn't there: nothing happens.
host.remove(host.me().client_id);
host.remove(9999);
host.remove(ada);
// Ada is told the session is over for her (no reconnecting); the others see her leave.
ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED)));
hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
connections_settle(&host, 1);
assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect");
assert!(!hp.left().contains(&bo), "only Ada was removed");
// Her address can't join again with the same invite (every guest here comes from 127.0.0.1).
let (_again, mut againp) = join(&invite, "Ada");
againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed"))));
assert!(!welcomed(&againp.events));
// Bo, already in, stays and can still talk to the host.
b.presence(presence(3.0, 4.0));
hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo)));
host.stop();
assert_eq!(host.connections(), 0);
}
+75 -3
View File
@@ -7,9 +7,10 @@
use std::time::Duration;
use figcraft_collab::message::{
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
PluginReq, ServerMsg,
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Resolve, ServerMsg,
};
use figcraft_engine::live::{Shared, SharedLink};
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
@@ -80,6 +81,74 @@ pub fn hosting(
Ok((info, replies))
}
/// Ask the server at `server` which file join code `code` opens: the file's id and its link's
/// token. Fails with the server's own words when it knows no such code (or the address tried too
/// many: the error starts with [`REFUSED`]); a server older than join codes can't read the
/// question ("couldn't be read").
pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
let ask = Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::Resolve(ask))?;
let answer = reply(&mut ws, limits, |m| match m {
ServerMsg::Resolved { file, token } => Some((file, token)),
_ => None,
})?;
close(ws);
Ok(answer)
}
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
/// make a new edit link and a new view link and ask for their join codes: what others join with.
/// A server older than join codes makes the links without codes.
pub fn share(
server: &Endpoint,
key: Option<&str>,
file: &str,
owner: &str,
revoke: &[String],
create: bool,
limits: &NetLimits,
timeout: Duration,
) -> Result<Option<Shared>, String> {
let ask = |requests: Vec<Admin>| match key {
Some(key) => request(server, key, requests, limits, timeout),
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
};
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
let mut made = Vec::new();
for reply in ask(requests)? {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
};
// Their codes, on a connection of its own: a server from before join codes can't read the
// request and closes it (the links are shared without codes then).
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
.unwrap_or_default()
.into_iter()
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut shared = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = shared(edit);
Ok(Some(Shared { edit, view: Some(shared(view)) }))
}
/// Send one library request about the file behind share link `link`; the reply. Fails when the
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
/// [`LibraryReply::Failed`] reply). Never echoes the link.
@@ -148,12 +217,15 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
}
/// How an error starts when the server said no (then its reason), rather than not answering.
pub(crate) const REFUSED: &str = "the server refused: ";
/// The next answer `pick` takes, or why there is none.
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
Ok(ServerMsg::Pong) => continue,
Ok(m) => pick(m).ok_or_else(|| "the server sent something other than an answer".into()),
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
+28 -28
View File
@@ -47,10 +47,10 @@ fn host_and_join() -> (Session, Session, u32, u32) {
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
assert_eq!(r["status"], "connecting");
pump(&mut host, &mut guest, "the join", |h, g| {
g.live.status == Status::Live && h.live.peers.len() == 1 && g.live.info.as_ref().is_some_and(|i| !i.color.is_empty())
g.live().status == Status::Live && h.live().peers.len() == 1 && g.live().info.as_ref().is_some_and(|i| !i.color.is_empty())
});
let hana = host.live.info.as_ref().unwrap().me;
let ada = guest.live.info.as_ref().unwrap().me;
let hana = host.live().info.as_ref().unwrap().me;
let ada = guest.live().info.as_ref().unwrap().me;
(host, guest, hana, ada)
}
@@ -62,23 +62,23 @@ fn host_join_and_see_each_other() {
assert_eq!(g["fileName"], "Untitled", "the host's file name");
assert_eq!(g["name"], "Ada");
assert_eq!(g["peers"][0]["name"], "Hana");
assert_eq!(host.live.peers[&ada].name, "Ada");
assert_eq!(host.live().peers[&ada].name, "Ada");
// The pointer: the guest's cursor shows up in the host's state.
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
h.live().presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
});
// A selection travels too.
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let id = r["id"].as_u64().unwrap();
host.execute("select.set", &json!({"ids": [id]})).unwrap();
pump(&mut host, &mut guest, "the host's selection", |_, g| {
g.live.presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
g.live().presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
});
// Leaving: the host sees it, the guest is back to no session.
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
assert_eq!(guest.live.status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live.peers.is_empty() && h.live.presence.is_empty());
assert_eq!(guest.live().status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live().peers.is_empty() && h.live().presence.is_empty());
host.execute("collab.stop", &json!({})).unwrap();
assert_eq!(host.live_summary()["status"], "off");
}
@@ -87,7 +87,7 @@ fn host_join_and_see_each_other() {
fn following_a_viewport_until_you_move() {
let (mut host, mut guest, hana, ada) = host_and_join();
host.execute("collab.follow", &json!({"client": ada})).unwrap();
assert_eq!(host.live.follow, Some(ada));
assert_eq!(host.live().follow, Some(ada));
// The guest pans and zooms: the host's view goes along.
{
let ds = guest.ds_mut().unwrap();
@@ -103,29 +103,29 @@ fn following_a_viewport_until_you_move() {
// The host pans: following stops, and later moves of the guest don't move the host.
host.ds_mut().unwrap().view.cx += 100.0;
host.live_sync();
assert_eq!(host.live.follow, None);
assert_eq!(host.live().follow, None);
guest.ds_mut().unwrap().view.cx = -9000.0;
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
h.live().presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
});
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
// Escape stops following too; following someone who isn't there fails.
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
guest.escape().unwrap();
assert_eq!(guest.live.follow, None);
assert_eq!(guest.live().follow, None);
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
// The followed peer leaving ends the follow.
host.execute("collab.follow", &json!({"client": ada})).unwrap();
guest.execute("collab.leave", &json!({})).unwrap();
pump(&mut host, &mut guest, "the leave", |h, _| h.live.follow.is_none() && h.live.peers.is_empty());
pump(&mut host, &mut guest, "the leave", |h, _| h.live().follow.is_none() && h.live().peers.is_empty());
}
#[test]
fn the_host_stopping_ends_the_session_for_guests() {
let (mut host, mut guest, _, _) = host_and_join();
host.execute("collab.stop", &json!({})).unwrap();
pump(&mut host, &mut guest, "the end", |_, g| g.live.status == Status::Off);
assert!(guest.live.peers.is_empty());
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
assert!(guest.live().peers.is_empty());
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
}
@@ -138,8 +138,8 @@ fn a_bad_invite_is_reported() {
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
guest.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live.status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live.peers.is_empty());
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live().status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live().peers.is_empty());
}
/// The synced properties of two documents agree (nothing to send from one to the other).
@@ -175,7 +175,7 @@ fn edits_sync_between_apps() {
}
};
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
h.live.syncing() && a.live.syncing() && b.live.syncing() && h.live.peers.len() == 2
h.live().syncing() && a.live().syncing() && b.live().syncing() && h.live().peers.len() == 2
});
// The guests got the host's file as a new tab.
assert_eq!(a.docs.len(), 2);
@@ -208,7 +208,7 @@ fn edits_sync_between_apps() {
// And the server's: someone joining now gets its document in the welcome.
let mut c = session("Cy");
c.execute("collab.join", &json!({"link": link})).unwrap();
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live.syncing());
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live().syncing());
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
host.execute("collab.stop", &json!({})).unwrap();
}
@@ -216,13 +216,13 @@ fn edits_sync_between_apps() {
#[test]
fn cursor_chat_and_spotlight_between_apps() {
let (mut host, mut guest, hana, ada) = host_and_join();
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live.signals && g.live.signals);
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
// The guest chats next to its cursor; the host sees it, cleaned, and then sees it close.
guest.execute("collab.cursor", &json!({"x": 40, "y": 30})).unwrap();
guest.execute("collab.cursorChat", &json!({"text": "over\there"})).unwrap();
pump(&mut host, &mut guest, "the chat", |h, _| h.live.chats.get(&ada).is_some_and(|t| t == "over here"));
pump(&mut host, &mut guest, "the chat", |h, _| h.live().chats.get(&ada).is_some_and(|t| t == "over here"));
guest.execute("collab.cursorChat", &json!({"text": ""})).unwrap();
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live.chats.is_empty());
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live().chats.is_empty());
// The host spotlights: the guest follows its view, page included.
let second = host.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
pump(&mut host, &mut guest, "the new page at the guest", |_, g| g.doc().unwrap().pages.contains(&second));
@@ -235,17 +235,17 @@ fn cursor_chat_and_spotlight_between_apps() {
}
pump(&mut host, &mut guest, "the guest following the spotlight", |_, g| {
let ds = g.ds().unwrap();
g.live.spotlight == Some(hana) && g.live.follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
g.live().spotlight == Some(hana) && g.live().follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
});
// A late joiner is told and follows too.
let mut late = session("Lin");
late.execute("collab.join", &json!({"link": host.live.info.as_ref().unwrap().link.clone()})).unwrap();
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live.follow == Some(hana) && l.live.spotlight == Some(hana));
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live().follow == Some(hana) && l.live().spotlight == Some(hana));
// The guest moves: it stops following, the spotlight stays; the host stops it for everyone.
guest.ds_mut().unwrap().view.cx += 300.0;
guest.live_sync();
assert_eq!((guest.live.follow, guest.live.spotlight), (None, Some(hana)));
assert_eq!((guest.live().follow, guest.live().spotlight), (None, Some(hana)));
host.execute("collab.spotlight", &json!({"on": false})).unwrap();
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live.spotlight.is_none());
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live.spotlight.is_none() && l.live.follow.is_none());
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none());
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none());
}
+52
View File
@@ -0,0 +1,52 @@
//! The sessions a link carries at once (the engine has one per document): each under the id the
//! engine gave it, with its own [`Inbox`], so what happens in one never reaches another and a
//! session that ended can't reach the next. The desktop link and the browser link both keep
//! theirs here.
use std::collections::BTreeMap;
use figcraft_engine::live::{Event, SessionId};
use crate::inbox::{Inbox, Waker};
/// Running sessions of kind `T` (a host or a guest connection) by id.
pub(crate) struct Sessions<T> {
wake: Waker,
running: BTreeMap<SessionId, (T, Inbox)>,
}
impl<T> Sessions<T> {
pub(crate) fn new(wake: Waker) -> Self {
Self { wake, running: BTreeMap::new() }
}
/// A mailbox for a session about to start.
pub(crate) fn inbox(&self) -> Inbox {
Inbox::new(self.wake.clone())
}
/// Session `id` started, with the mailbox its threads fill. Returns the session that had
/// the id, to be ended.
pub(crate) fn insert(&mut self, id: SessionId, session: T, inbox: Inbox) -> Option<T> {
self.running.insert(id, (session, inbox)).map(|(old, _)| old)
}
/// Session `id` is over: it comes back to be ended, and its mailbox is dropped.
pub(crate) fn remove(&mut self, id: SessionId) -> Option<T> {
self.running.remove(&id).map(|(session, _)| session)
}
pub(crate) fn get(&self, id: SessionId) -> Option<&T> {
self.running.get(&id).map(|(session, _)| session)
}
/// Every session's id, oldest first.
pub(crate) fn ids(&self) -> Vec<SessionId> {
self.running.keys().copied().collect()
}
/// What happened in each session since the last call, with its id.
pub(crate) fn poll(&self) -> Vec<(SessionId, Event)> {
self.running.iter().flat_map(|(id, (_, inbox))| inbox.drain().into_iter().map(|e| (*id, e))).collect()
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Sessions per document over the network: the link's session table, and two engine sessions in
//! one process that host and join several files at once through `collab.*` commands. Each file's
//! session has its own people, links, edits and presence; closing a file ends its session only.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::{Event, Live, Presence, Role, Status};
use serde_json::{Value, json};
use crate::NetLink;
use crate::loopback_tests::quick;
use crate::sessions::Sessions;
#[test]
fn each_session_has_its_own_mailbox() {
let woken = Arc::new(AtomicUsize::new(0));
let wake = woken.clone();
let mut table: Sessions<&str> = Sessions::new(Arc::new(move || {
wake.fetch_add(1, Ordering::SeqCst);
}));
let (a, b) = (table.inbox(), table.inbox());
assert!(table.insert(1, "host", a.clone()).is_none());
assert!(table.insert(2, "guest", b.clone()).is_none());
a.push(Event::PeerLeft(7));
b.push(Event::PeerLeft(9));
b.presence(3, Presence::default());
assert_eq!(woken.load(Ordering::SeqCst), 3, "every arrival wakes the UI");
assert_eq!(table.poll(), vec![(1, Event::PeerLeft(7)), (2, Event::PeerLeft(9)), (2, Event::Presence(3, Presence::default()))]);
assert!(table.poll().is_empty(), "taken once");
// A session that ended: what its threads still send goes nowhere, the other goes on.
assert_eq!(table.remove(1), Some("host"));
a.push(Event::PeerLeft(8));
b.push(Event::PeerLeft(10));
assert_eq!(table.poll(), vec![(2, Event::PeerLeft(10))]);
assert_eq!((table.ids(), table.get(1), table.get(2)), (vec![2], None, Some(&"guest")));
// An id started again replaces what had it, mailbox included.
assert_eq!(table.insert(2, "again", table.inbox()), Some("guest"));
b.push(Event::PeerLeft(11));
assert!(table.poll().is_empty());
assert_eq!(table.remove(3), None);
}
fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump the sessions (as their UIs would every frame) until `f` holds.
fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
let start = Instant::now();
loop {
for s in all.iter_mut() {
s.live_poll();
s.live_sync();
}
if f(all) {
return;
}
let states: Vec<String> = all.iter().map(|s| s.live_summary()["sessions"].to_string()).collect();
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {states:?}");
std::thread::sleep(Duration::from_millis(5));
}
}
fn link_of(v: &Value) -> String {
v["link"].as_str().unwrap().to_string()
}
fn uid_at(s: &Session, index: usize) -> u64 {
s.docs[index].uid
}
/// The session syncing through the host at `address` (a guest's).
fn joined<'a>(s: &'a Session, address: &str) -> Option<&'a Live> {
s.live.iter().find(|l| l.syncing() && l.info.as_ref().is_some_and(|i| i.role == Role::Guest && i.address == address))
}
fn has(s: &Session, uid: u64, node: u64) -> bool {
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.contains(NodeId(node)))
}
fn rect(s: &mut Session) -> u64 {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
}
#[test]
fn two_files_are_hosted_and_joined_independently() {
let mut host = session("Hana");
let mut guest = session("Ada");
// File A is hosted; a new file B isn't in a session until it is hosted too.
let a = host.execute("collab.host", &json!({"port": 0})).unwrap();
host.execute("file.new", &json!({"name": "Second"})).unwrap();
let off = host.execute("collab.peers", &json!({})).unwrap();
assert_eq!((&off["status"], &off["session"], &off["link"], &off["document"]), (&json!("off"), &Value::Null, &Value::Null, &Value::Null));
assert_eq!(off["sessions"].as_array().unwrap().len(), 1, "the first file's session goes on: {off}");
assert_eq!(off["sessions"][0]["document"], 0);
let b = host.execute("collab.host", &json!({"port": 0})).unwrap();
assert_ne!(a["address"], b["address"], "each file listens on its own port");
assert_ne!(a["session"], b["session"]);
assert_eq!(b["sessions"].as_array().unwrap().len(), 2);
let (addr_a, addr_b) = (a["address"].as_str().unwrap(), b["address"].as_str().unwrap());
let (ha, hb) = (uid_at(&host, 0), uid_at(&host, 1));
// The guest joins both, one right after the other: two more tabs, each with its session.
for l in [&a, &b] {
assert_eq!(guest.execute("collab.join", &json!({"link": link_of(l)})).unwrap()["status"], "connecting");
}
pump(&mut [&mut host, &mut guest], "both joins", |s| {
joined(&*s[1], addr_a).is_some() && joined(&*s[1], addr_b).is_some() && s[0].live_of(ha).peers.len() == 1 && s[0].live_of(hb).peers.len() == 1
});
assert_eq!(guest.docs.len(), 3, "the guest's own file and the two it joined");
let (ga, gb) = (joined(&guest, addr_a).unwrap().doc_uid.unwrap(), joined(&guest, addr_b).unwrap().doc_uid.unwrap());
assert_ne!(ga, gb);
assert_eq!(guest.live_of(uid_at(&guest, 0)).status, Status::Off, "the file the joins were started from is in no session");
assert_eq!(guest.docs.iter().find(|d| d.uid == gb).unwrap().doc.name, "Second");
// `collab.peers` is the file in front's: switching files switches it.
assert_eq!(host.live_summary()["link"], b["link"]);
host.execute("doc.switch", &json!({"index": 0})).unwrap();
assert_eq!(host.live_summary()["link"], a["link"]);
assert_eq!(host.live_summary()["document"], 0);
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front");
let in_a = rect(&mut host);
host.execute("doc.switch", &json!({"index": 1})).unwrap();
let in_b = rect(&mut host);
host.execute("node.set", &json!({"ids": [in_b], "props": {"name": "Only in B"}})).unwrap();
let name = |s: &Session, uid: u64, node: u64| s.docs.iter().find(|d| d.uid == uid).and_then(|d| d.doc.node(NodeId(node))).map(|n| n.name.clone());
// The rename is its own batch, behind the one that creates the layer: wait for it too.
pump(&mut [&mut host, &mut guest], "the edits at the guest", |s| has(&*s[1], ga, in_a) && name(&*s[1], gb, in_b).as_deref() == Some("Only in B"));
assert_ne!(name(&guest, ga, in_b).as_deref(), Some("Only in B"), "B's edit never reaches A");
assert_ne!(name(&host, ha, in_b).as_deref(), Some("Only in B"));
// And back: the guest edits its copy of A in the background tab's session.
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == ga).unwrap()})).unwrap();
let from_guest = rect(&mut guest);
guest.execute("node.set", &json!({"ids": [from_guest], "props": {"name": "From Ada"}})).unwrap();
pump(&mut [&mut host, &mut guest], "the guest's edit at the host", |s| name(&*s[0], ha, from_guest).as_deref() == Some("From Ada"));
assert_ne!(name(&host, hb, from_guest).as_deref(), Some("From Ada"));
// Presence stays in its session: the guest points in A (in front there), not in B.
let ada = |s: &Session, uid: u64| s.live_of(uid).peers.keys().next().copied().unwrap();
guest.execute("collab.cursor", &json!({"x": 64, "y": 48})).unwrap();
pump(&mut [&mut host, &mut guest], "the guest's cursor in A", |s| {
s[0].live_of(ha).presence.get(&ada(&*s[0], ha)).and_then(|p| p.cursor).is_some_and(|c| c.1 == 64.0 && c.2 == 48.0)
});
assert!(host.live_of(hb).presence.get(&ada(&host, hb)).is_none_or(|p| p.cursor.is_none()), "no cursor in the file that isn't in front");
// The host closes B: its session ends for everyone, A's goes on.
host.execute("file.close", &json!({"index": 1, "force": true})).unwrap();
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live.iter().count() == 1);
assert_eq!(guest.live_of(gb).status, Status::Off);
assert_eq!(guest.docs.len(), 3, "the guest keeps its copy of B");
assert_eq!((host.live_of(ha).status.clone(), host.live_of(ha).peers.len()), (Status::Live, 1));
assert_eq!(guest.live_of(ga).status, Status::Live);
let later = rect(&mut host);
pump(&mut [&mut host, &mut guest], "an edit after B closed", |s| has(&*s[1], ga, later));
assert!(!has(&guest, gb, later));
}
#[test]
fn hosting_one_file_while_joined_to_another() {
let mut x = session("Hana");
let mut y = session("Ada");
let from_x = link_of(&x.execute("collab.host", &json!({"port": 0})).unwrap());
let from_y = link_of(&y.execute("collab.host", &json!({"port": 0})).unwrap());
let (xu, yu) = (uid_at(&x, 0), uid_at(&y, 0));
// Each joins the other's file from the tab it hosts: the hosting goes on.
y.execute("collab.join", &json!({"link": from_x})).unwrap();
x.execute("collab.join", &json!({"link": from_y})).unwrap();
pump(&mut [&mut x, &mut y], "both joined", |s| {
s.iter().all(|s| s.docs.len() == 2 && s.live().syncing() && s.live().role() == Some(Role::Guest))
&& s[0].live_of(xu).peers.len() == 1
&& s[1].live_of(yu).peers.len() == 1
});
for (s, own) in [(&x, xu), (&y, yu)] {
let p = s.live_summary();
assert_eq!((&p["role"], &p["document"]), (&json!("guest"), &json!(1)), "the joined file is in front: {p}");
assert_eq!(p["sessions"].as_array().unwrap().len(), 2);
assert_eq!(s.live_of(own).role(), Some(Role::Host));
assert_eq!(s.live_of(own).status, Status::Live);
}
// Y edits X's file (its tab in front) and its own; each lands in its own file at X.
let in_x = rect(&mut y);
y.execute("doc.switch", &json!({"index": 0})).unwrap();
let in_y = rect(&mut y);
y.execute("node.set", &json!({"ids": [in_y], "props": {"name": "Hosted by Ada"}})).unwrap();
let named = |s: &Session, uid: u64| {
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.node(NodeId(in_y)).is_some_and(|n| n.name == "Hosted by Ada"))
};
let x_copy_of_y = x.live().doc_uid.unwrap();
pump(&mut [&mut x, &mut y], "Y's edits at X", |s| has(&*s[0], xu, in_x) && named(&*s[0], x_copy_of_y));
assert!(!named(&x, xu), "the edit of Y's file isn't in X's");
// Y leaves X's file: Y still hosts its own, with X in it.
y.execute("doc.switch", &json!({"index": 1})).unwrap();
assert_eq!(y.execute("collab.leave", &json!({})).unwrap()["ended"], true);
pump(&mut [&mut x, &mut y], "the leave", |s| s[0].live_of(xu).peers.is_empty());
assert_eq!((y.live().status.clone(), y.live.iter().count()), (Status::Off, 1));
assert_eq!((y.live_of(yu).status.clone(), y.live_of(yu).peers.len()), (Status::Live, 1));
assert_eq!(x.live_of(x_copy_of_y).status, Status::Live);
}
+7
View File
@@ -15,3 +15,10 @@ pub fn file_id() -> Result<String, String> {
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
}
/// 64 random bits (join codes are made from them).
pub fn random() -> Result<u64, String> {
let mut b = [0u8; 8];
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
Ok(u64::from_le_bytes(b))
}
+35 -31
View File
@@ -6,8 +6,9 @@
//! socket).
//!
//! The page runs everything on one thread: each session's socket, callbacks and timer live in a
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only the session's
//! number and its [`Inbox`]. Whatever arrives wakes egui through the [`Waker`]; nothing polls.
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only each session's
//! number there and its [`Inbox`](crate::inbox::Inbox). It joins any number of sessions at once
//! (one per open document). Whatever arrives wakes egui through the [`Waker`]; nothing polls.
//!
//! An `https://` page can't open an unencrypted `ws://` connection (browsers block mixed content)
//! except to this machine; [`NetLink::join`] says so up front.
@@ -17,16 +18,17 @@ use std::collections::BTreeMap;
use std::rc::Rc;
use figcraft_collab::message::{ClientMsg, Presence};
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionInfo};
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionId, SessionInfo};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
use web_time::Instant;
use crate::driver::{Driver, Socket};
use crate::inbox::{Inbox, Waker};
use crate::inbox::Waker;
use crate::invite::Invite;
use crate::join::GuestConfig;
use crate::sessions::Sessions;
/// Close code for leaving normally.
const CLOSE_NORMAL: u16 = 1000;
@@ -137,22 +139,21 @@ fn secure_page() -> bool {
web_sys::window().and_then(|w| w.location().protocol().ok()).is_some_and(|p| p == "https:")
}
/// The browser's [`LiveLink`]: joins one session at a time; hosting needs the desktop app.
/// The browser's [`LiveLink`]: joins sessions; hosting needs the desktop app.
pub struct NetLink {
wake: Waker,
active: Option<u64>,
inbox: Option<Inbox>,
/// Each session's number in the thread-local table.
sessions: Sessions<u64>,
}
impl NetLink {
/// `wake` is called whenever something arrives for the UI (egui's `request_repaint`).
pub fn new(wake: Waker) -> Self {
Self { wake, active: None, inbox: None }
Self { sessions: Sessions::new(wake) }
}
fn with(&self, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
if let Some(id) = self.active {
with_driver(id, f);
fn with(&self, session: SessionId, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
if let Some(id) = self.sessions.get(session) {
with_driver(*id, f);
}
}
}
@@ -162,51 +163,54 @@ impl LiveLink for NetLink {
false
}
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
fn host(&mut self, _: SessionId, _: HostOptions) -> Result<SessionInfo, String> {
Err("hosting a live session needs the desktop app; in the browser you can join one".into())
}
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
let (invite, code) = Invite::parse_join(link)?;
if secure_page() && !invite.server.secure && !invite.loopback() {
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// link (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or join from the desktop app".into());
}
self.leave();
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
let inbox = Inbox::new(self.wake.clone());
let driver = Driver::start(BrowserSocket { id, ws: None }, GuestConfig::new(invite, name.to_string()), inbox.clone(), Instant::now());
let inbox = self.sessions.inbox();
let cfg = GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) };
let driver = Driver::start(BrowserSocket { id, ws: None }, cfg, inbox.clone(), Instant::now());
let timer = Closure::<dyn FnMut()>::new(move || with_driver(id, |d, now| d.tick(now)));
let mut g = Guest { driver, timer, timeout: None };
g.arm();
if !g.driver.done() {
GUESTS.with(|t| t.borrow_mut().insert(id, Rc::new(RefCell::new(g))));
}
self.inbox = Some(inbox);
self.active = Some(id);
if let Some(old) = self.sessions.insert(session, id, inbox) {
with_driver(old, |d, now| d.leave(now));
}
Ok(())
}
fn leave(&mut self) {
self.with(|d, now| d.leave(now));
self.active = None;
self.inbox = None;
fn leave(&mut self, session: SessionId) {
if let Some(id) = self.sessions.remove(session) {
with_driver(id, |d, now| d.leave(now));
}
}
fn presence(&mut self, p: &Presence) {
self.with(|d, now| d.presence(p.clone(), now));
fn presence(&mut self, session: SessionId, p: &Presence) {
self.with(session, |d, now| d.presence(p.clone(), now));
}
fn send(&mut self, msg: ClientMsg) {
self.with(|d, now| d.send(msg, now));
fn send(&mut self, session: SessionId, msg: ClientMsg) {
self.with(session, |d, now| d.send(msg, now));
}
fn poll(&mut self) -> Vec<Event> {
self.inbox.as_ref().map(Inbox::drain).unwrap_or_default()
fn poll(&mut self) -> Vec<(SessionId, Event)> {
self.sessions.poll()
}
}
impl Drop for NetLink {
fn drop(&mut self) {
self.leave();
for id in self.sessions.ids() {
self.leave(id);
}
}
}
+14 -5
View File
@@ -84,7 +84,7 @@ impl Client {
/// Start from the server's `Welcome` (which must carry a snapshot).
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, access, .. } = welcome else {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
return Err(Error::Protocol("expected a welcome with the document".into()));
};
let mut confirmed = Replica::from_snapshot(&snapshot)?;
@@ -97,7 +97,9 @@ impl Client {
confirmed,
seq,
pending: VecDeque::new(),
next_batch: 1,
// After the batches the server already processed for this id (a client that starts
// over under the id it had, e.g. after its app crashed: lower ids would be ignored).
next_batch: processed.last_batch.saturating_add(1),
next_id,
peers: peers.into_iter().map(|p| (p.client_id, p)).collect(),
presence: BTreeMap::new(),
@@ -305,7 +307,8 @@ impl Client {
/// Queue `changes` as a new batch (already shown in the view).
fn queue(&mut self, changes: Vec<Change>, kind: BatchKind, step: Option<u64>) {
let batch_id = self.next_batch;
self.next_batch += 1;
// `next_batch` starts from the welcome's `last_batch`, the host's word: saturate.
self.next_batch = self.next_batch.saturating_add(1);
self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false });
}
@@ -451,7 +454,9 @@ impl Client {
}
// Cursor chat, spotlights and what a server says about its hosting are the app's (the
// transport hands them over).
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } => Ok(Vec::new()),
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } | ServerMsg::Resolved { .. } => {
Ok(Vec::new())
}
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
if session != self.session || client_id != self.id {
@@ -480,7 +485,11 @@ impl Client {
/// A batch the server accepted (in order). Follow-ups it causes are queued.
fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> {
if acc.seq != self.seq + 1 {
// The host's sequence number is untrusted: at `u64::MAX` it can't step past it.
let Some(next) = self.seq.checked_add(1) else {
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
};
if acc.seq != next {
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
}
if acc.author != self.id {
+43
View File
@@ -0,0 +1,43 @@
//! A client's handling of the host's messages: sequence numbers and batch ids come from the host,
//! so they are treated as hostile input (they must never overflow).
use figcraft_doc::NodeKind;
use crate::message::{Accepted, ClientMsg, Hello, ServerMsg};
use crate::sim_tests::base;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
fn hello() -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
h
}
/// A welcome whose sequence numbers are what a lying host would send.
fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg {
let mut s = Server::new(base(), "tok", Limits::default());
let (_, out) = s.join(hello()).unwrap();
let mut welcome = out
.into_iter()
.find_map(|o| match o {
Outgoing::To(_, m) => Some(m),
_ => None,
})
.unwrap();
let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") };
*seq0 = seq;
processed.last_batch = last_batch;
welcome
}
#[test]
fn hostile_sequence_numbers_do_not_overflow_the_client() {
let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap();
// This client's own first batch must not overflow its batch counter.
let mut d = c.document().clone();
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
assert!(c.edit(&d).unwrap().is_some(), "the edit is batched");
// A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow.
let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() };
assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err());
}
+170
View File
@@ -0,0 +1,170 @@
//! Join codes: the short text someone types to join a shared file, in place of an invite link.
//!
//! Both kinds use Crockford's base32 alphabet (digits and capitals without `I`, `L`, `O`, `U`), so
//! a code survives being read aloud or copied by hand: reading one ignores case, spaces and dashes
//! and takes `O` as `0` and `I` or `L` as `1`.
//!
//! - An **online** code ([`ONLINE_LEN`] symbols, 45 random bits, shown `K7M-2QX-9TD`) is a name a
//! server gave one share link: the server is asked which file it opens.
//! - A **local network** code ([`LOCAL_LEN`] symbols, shown in fives) has no server to ask, so it
//! carries what a joiner needs ([`Local`]): the host's IPv4 address, its port, the session's
//! 44-bit secret and 8 check bits that catch typing mistakes before anything connects.
use std::net::Ipv4Addr;
/// Crockford's base32 symbols, by value.
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// Symbols in an online code.
pub const ONLINE_LEN: usize = 9;
/// Symbols in a local network code.
pub const LOCAL_LEN: usize = 20;
/// Bits of a local session's secret.
const SECRET_BITS: u32 = 44;
/// Longest text read as a code (anything longer isn't one).
const MAX_TYPED: usize = 128;
/// The file id every local session uses (its port tells sessions apart).
pub const LOCAL_FILE: &str = "local";
const TOKEN_PREFIX: &str = "local-";
/// The value of a typed symbol: its own, or the digit it is mistaken for.
fn value(c: char) -> Option<u8> {
let c = match c.to_ascii_uppercase() {
'O' => '0',
'I' | 'L' => '1',
c => c,
};
ALPHABET.iter().position(|a| char::from(*a) == c).and_then(|i| u8::try_from(i).ok())
}
/// The symbol for the low five bits of `v`.
fn symbol(v: u8) -> char {
char::from(ALPHABET.get(usize::from(v & 31)).copied().unwrap_or(b'0'))
}
/// The lowest `n` symbols of `v`, most significant first.
fn symbols(v: u128, n: usize) -> String {
(0..n).rev().map(|i| symbol((v >> (5 * i)).to_le_bytes()[0])).collect()
}
/// What someone typed, as a code's symbols: capitals, without spaces and dashes, look-alikes
/// read as the digits they stand for. Errors say what to fix (they never echo the text).
pub fn normalize(text: &str) -> Result<String, String> {
if text.len() > MAX_TYPED {
return Err("that is too long to be a join code".into());
}
let typed = text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '-' | '–' | '—' | '_' | '.'));
let code = typed
.map(|c| value(c).map(symbol))
.collect::<Option<String>>()
.ok_or("a join code has only letters and digits (and no U): check it and try again")?;
if code.is_empty() {
return Err("type the join code you were given".into());
}
Ok(code)
}
/// A code as it is shown: in groups, with dashes (`K7M-2QX-9TD`; local codes in fives).
pub fn grouped(code: &str) -> String {
let n = if code.len() == ONLINE_LEN { 3 } else { 5 };
let chars: Vec<char> = code.chars().collect();
chars.chunks(n).map(|c| c.iter().collect::<String>()).collect::<Vec<_>>().join("-")
}
/// An online code from 45 random bits (the rest of `random` is ignored).
pub fn online(random: u64) -> String {
symbols(u128::from(random), ONLINE_LEN)
}
/// What a local network code carries.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Local {
pub ip: Ipv4Addr,
pub port: u16,
/// The session's secret ([`SECRET_BITS`] bits).
pub secret: u64,
}
impl Local {
/// For a session at `ip:port` with a secret made from `random` (its low 44 bits).
pub fn new(ip: Ipv4Addr, port: u16, random: u64) -> Self {
Self { ip, port, secret: random & ((1 << SECRET_BITS) - 1) }
}
/// Address, port and secret as one number (92 bits).
fn bits(&self) -> u128 {
(u128::from(u32::from(self.ip)) << 60) | (u128::from(self.port) << SECRET_BITS) | u128::from(self.secret)
}
/// The code's symbols (show them [`grouped`]).
pub fn code(&self) -> String {
let bits = self.bits();
symbols((bits << 8) | u128::from(check(bits)), LOCAL_LEN)
}
/// Read a [`normalize`]d local code.
fn read(code: &str) -> Result<Local, String> {
const TYPO: &str = "that local network code has a typing mistake: check it and try again";
let v = code.chars().try_fold(0u128, |v, c| value(c).map(|s| (v << 5) | u128::from(s))).ok_or(TYPO)?;
let bits = v >> 8;
let local = Local {
ip: Ipv4Addr::from(u32::try_from(bits >> 60).map_err(|_| TYPO)?),
port: u16::try_from((bits >> SECRET_BITS) & 0xFFFF).map_err(|_| TYPO)?,
secret: u64::try_from(bits & ((1 << SECRET_BITS) - 1)).map_err(|_| TYPO)?,
};
if u128::from(check(bits)) != v & 0xFF || local.port == 0 || local.ip.is_unspecified() {
return Err(TYPO.into());
}
Ok(local)
}
/// The session's token: what its host checks a hello against.
pub fn token(&self) -> String {
format!("{TOKEN_PREFIX}{:011x}", self.secret)
}
/// The session's invite link (what a joiner's app connects with).
pub fn link(&self) -> String {
format!("figcraft://join?host={}:{}&file={LOCAL_FILE}&token={}", self.ip, self.port, self.token())
}
/// The session behind a local session's invite link (`None` for any other link: only IPv4
/// hosts with a code-sized secret have a code).
pub fn from_link(link: &str) -> Option<Local> {
let query = link.strip_prefix("figcraft://join?")?;
let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v));
let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?;
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?;
let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? };
(get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local)
}
}
/// Eight check bits over a local code's 92 bits (FNV-1a, folded): not a secret, only a typo catcher.
fn check(bits: u128) -> u8 {
let h = bits.to_be_bytes().iter().fold(0x811C_9DC5_u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(0x0100_0193));
h.to_be_bytes().iter().fold(0, |a, b| a ^ b)
}
/// A code someone typed, by kind.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Code {
/// A server's code (its symbols): the server says which file it opens.
Online(String),
Local(Local),
}
/// Read what someone typed as a join code of either kind.
pub fn parse(text: &str) -> Result<Code, String> {
let code = normalize(text)?;
match code.len() {
ONLINE_LEN => Ok(Code::Online(code)),
LOCAL_LEN => Local::read(&code).map(Code::Local),
n => Err(format!("a join code has {ONLINE_LEN} characters ({LOCAL_LEN} on the local network); that one has {n}")),
}
}
#[cfg(test)]
#[path = "code_tests.rs"]
mod tests;
+149
View File
@@ -0,0 +1,149 @@
//! Join codes: what typing tolerates, both kinds round trip, and tampered local codes are caught.
use std::net::Ipv4Addr;
use super::*;
#[test]
fn typed_codes_are_normalised() {
let table = [
("K7M-2QX-9TD", Ok("K7M2QX9TD")),
(" k7m 2qx 9td\n", Ok("K7M2QX9TD")),
("k7m2qx9td", Ok("K7M2QX9TD")),
("K7M–2QX—9TD", Ok("K7M2QX9TD")),
("K7M_2QX.9TD", Ok("K7M2QX9TD")),
// Look-alikes are the digits they are mistaken for.
("O0o-IiL-l1X", Ok("00011111X")),
("", Err("type the join code")),
(" - ", Err("type the join code")),
("K7M-2QU-9TD", Err("no U")),
("K7M/2QX", Err("only letters and digits")),
("K7M2QX9T€", Err("only letters and digits")),
("figcraft://join?host=1.2.3.4:5&file=f&token=t", Err("only letters and digits")),
];
for (typed, want) in table {
match (normalize(typed), want) {
(Ok(got), Ok(want)) => assert_eq!(got, want, "{typed:?}"),
(Err(e), Err(part)) => assert!(e.contains(part), "{typed:?}: {e}"),
(got, want) => panic!("{typed:?}: got {got:?}, wanted {want:?}"),
}
}
assert!(normalize(&"A".repeat(500)).is_err(), "too long to be a code");
}
#[test]
fn errors_never_echo_what_was_typed() {
for typed in ["s3cr3t!!", "SECRETSECRETSECRETSECRE", "figcraft://join?token=hunter2hunter2hunter2"] {
let e = parse(typed).unwrap_err();
assert!(!e.to_lowercase().contains(&typed.to_lowercase()[..6]), "{e}");
}
}
#[test]
fn online_codes_are_nine_unambiguous_symbols() {
for random in [0, 1, u64::MAX, 0x1234_5678_9ABC_DEF0] {
let code = online(random);
assert_eq!(code.len(), ONLINE_LEN);
assert!(code.chars().all(|c| c.is_ascii_digit() || (c.is_ascii_uppercase() && !"ILOU".contains(c))), "{code}");
assert_eq!(parse(&grouped(&code).to_lowercase()), Ok(Code::Online(code)));
}
assert_eq!(grouped("K7M2QX9TD"), "K7M-2QX-9TD");
assert_ne!(online(1), online(2));
// Only 45 bits count.
assert_eq!(online(1 << 45), online(0));
}
#[test]
fn local_codes_round_trip() {
for (ip, port, random) in [([192, 168, 1, 20], 47_900, 0x0ABC_DEF0_1234_u64), ([10, 0, 0, 1], 1, 0), ([127, 0, 0, 1], 65_535, u64::MAX)] {
let local = Local::new(Ipv4Addr::from(ip), port, random);
let code = local.code();
assert_eq!(code.len(), LOCAL_LEN);
let shown = grouped(&code);
assert_eq!(shown.len(), LOCAL_LEN + 3, "four groups of five: {shown}");
assert_eq!(parse(&shown), Ok(Code::Local(local)));
assert_eq!(parse(&shown.to_lowercase().replace('-', " ")), Ok(Code::Local(local)));
// The link a joiner connects with names the same session, and gives the code back.
let link = local.link();
assert_eq!(link, format!("figcraft://join?host={}:{port}&file=local&token={}", Ipv4Addr::from(ip), local.token()));
assert_eq!(local.token().len(), 17);
assert_eq!(Local::from_link(&link), Some(local));
}
}
#[test]
fn tampered_local_codes_are_caught() {
let code = Local::new(Ipv4Addr::new(192, 168, 1, 20), 47_900, 0x123_4567_89AB).code();
let symbols = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let (mut tried, mut missed) = (0, 0);
for at in 0..code.len() {
for s in symbols.chars().filter(|s| code.chars().nth(at) != Some(*s)) {
let mut changed: Vec<char> = code.chars().collect();
changed[at] = s;
tried += 1;
missed += usize::from(parse(&changed.iter().collect::<String>()).is_ok());
}
}
// Eight check bits: about one changed symbol in 256 slips through (and then fails to join).
assert!(missed * 50 < tried, "{missed} of {tried} single-symbol changes went unnoticed");
// Two symbols swapped.
let mut swapped: Vec<char> = code.chars().collect();
let at = (0..19).find(|i| swapped[*i] != swapped[*i + 1]).unwrap();
swapped.swap(at, at + 1);
assert!(parse(&swapped.iter().collect::<String>()).unwrap_err().contains("typing mistake"));
// Wrong lengths say what a code looks like.
assert!(parse(&code[..19]).unwrap_err().contains("9 characters"));
assert!(parse("K7M2QX9T").unwrap_err().contains("that one has 8"));
}
#[test]
fn only_local_session_links_have_a_code() {
for link in [
"figcraft://join?host=192.168.1.20:47900&file=abc&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:47900&file=local&token=AbCdEfGhIjKlMnOpQrStUv",
"figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az",
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
"",
] {
assert_eq!(Local::from_link(link), None, "{link}");
}
}
#[test]
fn the_new_messages_go_last_on_the_wire() {
use crate::message::{Admin, AdminReply, ClientMsg, Resolve, ServerMsg, encode};
// postcard writes a variant's index: an older peer reads everything it knew unchanged, and
// fails only on the variants added for join codes (which is how the app tells).
let tag = |bytes: Vec<u8>| bytes[1];
let ask = Resolve { protocol: 2, client_version: String::new(), code: "K7M2QX9TD".into() };
assert_eq!(tag(encode(&ClientMsg::Ping).unwrap()), 4);
assert_eq!(tag(encode(&ClientMsg::Report { reason: String::new() }).unwrap()), 13);
assert_eq!(tag(encode(&ClientMsg::Resolve(ask)).unwrap()), 14);
assert_eq!(tag(encode(&ServerMsg::Pong).unwrap()), 7);
assert_eq!(tag(encode(&ServerMsg::Reported { error: None }).unwrap()), 13);
assert_eq!(tag(encode(&ServerMsg::Resolved { file: String::new(), token: String::new() }).unwrap()), 14);
assert_eq!(tag(encode(&Admin::Blocked).unwrap()), 11);
assert_eq!(tag(encode(&Admin::Code { file: String::new(), token: String::new() }).unwrap()), 12);
assert_eq!(tag(encode(&AdminReply::Blocked(Vec::new())).unwrap()), 10);
assert_eq!(tag(encode(&AdminReply::Code { code: String::new() }).unwrap()), 11);
}
#[test]
fn junk_never_panics() {
let mut seed = 0x9E37_79B9_7F4A_7C15_u64;
for _ in 0..20_000 {
let len = (seed % 40) as usize;
let text: String = (0..len)
.map(|_| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
char::from_u32((seed >> 40) as u32 % 0x250).unwrap_or('-')
})
.collect();
let _ = parse(&text);
let _ = Local::from_link(&text);
let _ = grouped(&text);
}
}
+1
View File
@@ -75,6 +75,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
ClientMsg::Admin(Admin::Blocked),
ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }),
ClientMsg::Report { reason: "Spam ☕".into() },
];
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
+7 -1
View File
@@ -109,7 +109,13 @@ fn kind_changes_and_transforms_round_trip() {
}
fn style(id: &str) -> Style {
Style { id: id.into(), name: format!("S {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] } }
Style {
id: id.into(),
name: format!("S {id}"),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] },
bound: Default::default(),
}
}
#[test]
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::sim::Sim;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
fn style(id: String, name: &str) -> Style {
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() }, bound: Default::default() }
}
fn comment(id: u64, doc: &Document) -> Comment {
+5
View File
@@ -23,6 +23,7 @@
pub mod change;
pub mod client;
pub mod code;
mod diff;
mod docprops;
pub mod frac;
@@ -122,6 +123,8 @@ impl Recompute for NoRecompute {
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
}
#[cfg(test)]
mod client_tests;
#[cfg(test)]
mod codec_tests;
#[cfg(test)]
@@ -137,6 +140,8 @@ mod merge_list_tests;
#[cfg(test)]
mod merge_tests;
#[cfg(test)]
mod owned_tests;
#[cfg(test)]
mod reconnect_tests;
#[cfg(test)]
mod server_tests;
+7 -1
View File
@@ -27,7 +27,13 @@ fn files() -> Files {
}
fn style(id: &str, c: Color) -> Style {
Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(c)] } }
Style {
id: id.into(),
name: format!("Style {id}"),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(c)] },
bound: Default::default(),
}
}
fn plan_of(f: &Files) -> Plan {
+52
View File
@@ -266,6 +266,32 @@ pub enum Admin {
Unblock { ip: String },
/// The blocked addresses.
Blocked,
/// A join code for the link of `file` whose token this is (the token proves the link and lets
/// the server give it to whoever types the code): [`AdminReply::Code`]. A link has one code:
/// asking again replaces it. Servers older than join codes can't read this request.
Code { file: String, token: String },
/// On an open hosting connection (no file named in its opening): what became of the files
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
Owned { files: Vec<(String, String)> },
/// Remove client `client` from `file`'s session: they are disconnected and their address
/// can't join the file again while its session lasts. With the server key, or on an open
/// hosting connection with `file`'s owner token. Servers older than this request can't read it.
RemovePeer { file: String, client: u32 },
}
/// The most files one [`Admin::Owned`] asks about.
pub const MAX_OWNED: usize = 200;
/// A file its owner asked about ([`Admin::Owned`]).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwnedFile {
/// Its name on the server.
pub name: String,
/// The ids of its share links (the owner's own and the ones made for others).
pub links: Vec<String>,
/// When it is deleted if nobody opens it (seconds since 1970).
pub expires: Option<u64>,
}
/// A file a server keeps.
@@ -336,6 +362,13 @@ pub enum AdminReply {
Listing(Vec<FileRow>),
Reports(Vec<ReportInfo>),
Blocked(Vec<String>),
/// A link's new join code (shown this once; the server keeps only a hash).
Code {
code: String,
},
/// The answer to [`Admin::Owned`], one per pair asked about, in order. `None`: no file of that
/// id is managed by that token here (removed, expired, or never there).
Owned(Vec<Option<OwnedFile>>),
}
/// A file with what its operator moderates by.
@@ -380,6 +413,17 @@ pub struct OpenHosting {
pub owner: String,
}
/// Asks a server which file a join code opens (the first message on a connection, instead of
/// [`Hello`]). Answered with [`ServerMsg::Resolved`], then the connection closes; a wrong code, too
/// many wrong ones from an address, or a server that hosts no files is `Refused`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Resolve {
pub protocol: u32,
pub client_version: String,
/// The code's symbols ([`crate::code::normalize`]d).
pub code: String,
}
/// What a server says about its hosting: whether anyone may publish, who runs it and its terms,
/// how long files published without the key are kept unopened, and the largest file.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -580,6 +624,8 @@ pub enum ClientMsg {
Report {
reason: String,
},
/// Ask which file a join code opens (the first message on a connection, instead of `Hello`).
Resolve(Resolve),
}
/// Server → client.
@@ -642,6 +688,12 @@ pub enum ServerMsg {
Reported {
error: Option<String>,
},
/// The answer to [`ClientMsg::Resolve`]: the file the code opens and its link's token (what a
/// `Hello` joins with).
Resolved {
file: String,
token: String,
},
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
+53
View File
@@ -0,0 +1,53 @@
//! `Admin::Owned` and its answer on the wire: both encodings round-trip, the new variants sit at
//! the end of their enums (so every message an older peer reads keeps its bytes), and an older
//! peer, which doesn't know them, fails to read them instead of misreading them.
use crate::message::{Admin, AdminReply, ClientMsg, OwnedFile, ServerMsg, decode, decode_json, encode, encode_json};
fn asked() -> ClientMsg {
ClientMsg::Admin(Admin::Owned { files: vec![("f1".into(), "OwnerToken".into()), (String::new(), String::new())] })
}
fn answered() -> ServerMsg {
let file = OwnedFile { name: "Homepage".into(), links: vec!["a".into(), "b".into()], expires: Some(1_900_000_000) };
ServerMsg::Admin(Box::new(AdminReply::Owned(vec![Some(file), None])))
}
#[test]
fn both_encodings_round_trip() {
let (c, s) = (asked(), answered());
assert_eq!(decode::<ClientMsg>(&encode(&c).unwrap(), 1 << 20).unwrap(), c);
assert_eq!(decode::<ServerMsg>(&encode(&s).unwrap(), 1 << 20).unwrap(), s);
let json = encode_json(&c).unwrap();
assert!(json.contains(r#""owned":{"files":[["f1","OwnerToken"],["",""]]}"#), "{json}");
assert_eq!(decode_json::<ClientMsg>(&json, 1 << 20).unwrap(), c);
let json = encode_json(&s).unwrap();
assert!(json.contains(r#""owned":[{"name":"Homepage","links":["a","b"],"expires":1900000000},null]"#), "{json}");
assert_eq!(decode_json::<ServerMsg>(&json, 1 << 20).unwrap(), s);
}
#[test]
fn the_new_variants_are_the_last_of_their_enums() {
// Postcard writes a variant as its index: the last byte before the payload of an `Admin`
// request is the variant's. `Code` was the last one before; `Owned` follows it.
let index = |a: Admin| postcard::to_allocvec(&a).unwrap()[0];
let code = index(Admin::Code { file: String::new(), token: String::new() });
assert_eq!(index(Admin::Owned { files: vec![] }), code + 1);
let index = |r: AdminReply| postcard::to_allocvec(&r).unwrap()[0];
assert_eq!(index(AdminReply::Owned(vec![])), index(AdminReply::Code { code: String::new() }) + 1);
// A peer from before them reads an unknown variant as an error, never as another message:
// one index further is as unknown to this version as `Owned` is to an older one.
let mut bytes = encode(&asked()).unwrap();
let at = bytes.iter().position(|b| *b == code + 1).unwrap();
bytes[at] = code + 2;
assert!(decode::<ClientMsg>(&bytes, 1 << 20).is_err());
}
#[test]
fn hostile_answers_are_refused_by_size_not_trusted() {
// A count that promises more than the message holds doesn't allocate or read past the end.
let mut bytes = encode(&answered()).unwrap();
bytes.truncate(bytes.len() - 3);
assert!(decode::<ServerMsg>(&bytes, 1 << 20).is_err());
assert!(decode::<ServerMsg>(&encode(&answered()).unwrap(), 4).is_err(), "over the size limit");
}
+1
View File
@@ -392,6 +392,7 @@ impl Server {
| ClientMsg::OpenPlugins(_)
| ClientMsg::Plugins(_)
| ClientMsg::OpenHosting(_)
| ClientMsg::Resolve(_)
| ClientMsg::Report { .. } => {
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
}
+7 -1
View File
@@ -109,7 +109,13 @@ pub(crate) fn random_edit(doc: &mut Document, rng: &mut Rng, derive: &dyn Recomp
doc.styles.remove(i);
}
Some(i) => doc.styles[i].name = format!("Renamed {}", rng.below(100)),
None => doc.styles.push(Style { id, name: "S".into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } }),
None => doc.styles.push(Style {
id,
name: "S".into(),
description: String::new(),
kind: StyleKind::Paint { paints: vec![] },
bound: Default::default(),
}),
}
}
}
+19 -16
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod named;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
@@ -77,6 +78,8 @@ impl Color {
pub const DEFAULT_SHAPE: Color = Color::from_rgb8_const(0xD9, 0xD9, 0xD9);
/// Default page background (#F5F5F5).
pub const DEFAULT_PAGE: Color = Color::from_rgb8_const(0xF5, 0xF5, 0xF5);
/// The background of a page made while the interface shows its dark theme (#1E1E1E).
pub const DARK_PAGE: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E);
pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r: clamp01(r), g: clamp01(g), b: clamp01(b), a: clamp01(a) }
@@ -137,7 +140,13 @@ impl Color {
Some(Self::from_rgba8(r, g, b, a))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names.
/// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name
/// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque).
pub fn from_hex_or_name(s: &str) -> Option<Self> {
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
pub fn parse_css(s: &str) -> Option<Self> {
let t = s.trim().to_ascii_lowercase();
if t.starts_with('#') {
@@ -164,21 +173,10 @@ impl Color {
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
let named = match t.as_str() {
"black" => Color::BLACK,
"white" => Color::WHITE,
"transparent" => Color::TRANSPARENT,
"red" => Color::from_rgb8_const(255, 0, 0),
"green" => Color::from_rgb8_const(0, 128, 0),
"lime" => Color::from_rgb8_const(0, 255, 0),
"blue" => Color::from_rgb8_const(0, 0, 255),
"yellow" => Color::from_rgb8_const(255, 255, 0),
"orange" => Color::from_rgb8_const(255, 165, 0),
"purple" => Color::from_rgb8_const(128, 0, 128),
"gray" | "grey" => Color::from_rgb8_const(128, 128, 128),
_ => return Self::from_hex(&t),
};
Some(named)
if t == "transparent" {
return Some(Color::TRANSPARENT);
}
Self::from_hex_or_name(&t)
}
/// CSS `rgba(...)` / hex text for copy-as-CSS.
@@ -199,6 +197,11 @@ impl Color {
0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b)
}
/// Whether black on this colour reads worse than white (a dark page: new text is white).
pub fn is_dark(self) -> bool {
self.contrast(Color::WHITE) > self.contrast(Color::BLACK)
}
/// WCAG contrast ratio between two opaque colours.
pub fn contrast(self, other: Color) -> f32 {
let (a, b) = (self.luminance() + 0.05, other.luminance() + 0.05);
+197
View File
@@ -0,0 +1,197 @@
//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as
//! `FF0000`.
/// Every CSS named colour except `transparent`, sorted by name for binary search.
const NAMED: [(&str, [u8; 3]); 148] = [
("aliceblue", [240, 248, 255]),
("antiquewhite", [250, 235, 215]),
("aqua", [0, 255, 255]),
("aquamarine", [127, 255, 212]),
("azure", [240, 255, 255]),
("beige", [245, 245, 220]),
("bisque", [255, 228, 196]),
("black", [0, 0, 0]),
("blanchedalmond", [255, 235, 205]),
("blue", [0, 0, 255]),
("blueviolet", [138, 43, 226]),
("brown", [165, 42, 42]),
("burlywood", [222, 184, 135]),
("cadetblue", [95, 158, 160]),
("chartreuse", [127, 255, 0]),
("chocolate", [210, 105, 30]),
("coral", [255, 127, 80]),
("cornflowerblue", [100, 149, 237]),
("cornsilk", [255, 248, 220]),
("crimson", [220, 20, 60]),
("cyan", [0, 255, 255]),
("darkblue", [0, 0, 139]),
("darkcyan", [0, 139, 139]),
("darkgoldenrod", [184, 134, 11]),
("darkgray", [169, 169, 169]),
("darkgreen", [0, 100, 0]),
("darkgrey", [169, 169, 169]),
("darkkhaki", [189, 183, 107]),
("darkmagenta", [139, 0, 139]),
("darkolivegreen", [85, 107, 47]),
("darkorange", [255, 140, 0]),
("darkorchid", [153, 50, 204]),
("darkred", [139, 0, 0]),
("darksalmon", [233, 150, 122]),
("darkseagreen", [143, 188, 143]),
("darkslateblue", [72, 61, 139]),
("darkslategray", [47, 79, 79]),
("darkslategrey", [47, 79, 79]),
("darkturquoise", [0, 206, 209]),
("darkviolet", [148, 0, 211]),
("deeppink", [255, 20, 147]),
("deepskyblue", [0, 191, 255]),
("dimgray", [105, 105, 105]),
("dimgrey", [105, 105, 105]),
("dodgerblue", [30, 144, 255]),
("firebrick", [178, 34, 34]),
("floralwhite", [255, 250, 240]),
("forestgreen", [34, 139, 34]),
("fuchsia", [255, 0, 255]),
("gainsboro", [220, 220, 220]),
("ghostwhite", [248, 248, 255]),
("gold", [255, 215, 0]),
("goldenrod", [218, 165, 32]),
("gray", [128, 128, 128]),
("green", [0, 128, 0]),
("greenyellow", [173, 255, 47]),
("grey", [128, 128, 128]),
("honeydew", [240, 255, 240]),
("hotpink", [255, 105, 180]),
("indianred", [205, 92, 92]),
("indigo", [75, 0, 130]),
("ivory", [255, 255, 240]),
("khaki", [240, 230, 140]),
("lavender", [230, 230, 250]),
("lavenderblush", [255, 240, 245]),
("lawngreen", [124, 252, 0]),
("lemonchiffon", [255, 250, 205]),
("lightblue", [173, 216, 230]),
("lightcoral", [240, 128, 128]),
("lightcyan", [224, 255, 255]),
("lightgoldenrodyellow", [250, 250, 210]),
("lightgray", [211, 211, 211]),
("lightgreen", [144, 238, 144]),
("lightgrey", [211, 211, 211]),
("lightpink", [255, 182, 193]),
("lightsalmon", [255, 160, 122]),
("lightseagreen", [32, 178, 170]),
("lightskyblue", [135, 206, 250]),
("lightslategray", [119, 136, 153]),
("lightslategrey", [119, 136, 153]),
("lightsteelblue", [176, 196, 222]),
("lightyellow", [255, 255, 224]),
("lime", [0, 255, 0]),
("limegreen", [50, 205, 50]),
("linen", [250, 240, 230]),
("magenta", [255, 0, 255]),
("maroon", [128, 0, 0]),
("mediumaquamarine", [102, 205, 170]),
("mediumblue", [0, 0, 205]),
("mediumorchid", [186, 85, 211]),
("mediumpurple", [147, 112, 219]),
("mediumseagreen", [60, 179, 113]),
("mediumslateblue", [123, 104, 238]),
("mediumspringgreen", [0, 250, 154]),
("mediumturquoise", [72, 209, 204]),
("mediumvioletred", [199, 21, 133]),
("midnightblue", [25, 25, 112]),
("mintcream", [245, 255, 250]),
("mistyrose", [255, 228, 225]),
("moccasin", [255, 228, 181]),
("navajowhite", [255, 222, 173]),
("navy", [0, 0, 128]),
("oldlace", [253, 245, 230]),
("olive", [128, 128, 0]),
("olivedrab", [107, 142, 35]),
("orange", [255, 165, 0]),
("orangered", [255, 69, 0]),
("orchid", [218, 112, 214]),
("palegoldenrod", [238, 232, 170]),
("palegreen", [152, 251, 152]),
("paleturquoise", [175, 238, 238]),
("palevioletred", [219, 112, 147]),
("papayawhip", [255, 239, 213]),
("peachpuff", [255, 218, 185]),
("peru", [205, 133, 63]),
("pink", [255, 192, 203]),
("plum", [221, 160, 221]),
("powderblue", [176, 224, 230]),
("purple", [128, 0, 128]),
("rebeccapurple", [102, 51, 153]),
("red", [255, 0, 0]),
("rosybrown", [188, 143, 143]),
("royalblue", [65, 105, 225]),
("saddlebrown", [139, 69, 19]),
("salmon", [250, 128, 114]),
("sandybrown", [244, 164, 96]),
("seagreen", [46, 139, 87]),
("seashell", [255, 245, 238]),
("sienna", [160, 82, 45]),
("silver", [192, 192, 192]),
("skyblue", [135, 206, 235]),
("slateblue", [106, 90, 205]),
("slategray", [112, 128, 144]),
("slategrey", [112, 128, 144]),
("snow", [255, 250, 250]),
("springgreen", [0, 255, 127]),
("steelblue", [70, 130, 180]),
("tan", [210, 180, 140]),
("teal", [0, 128, 128]),
("thistle", [216, 191, 216]),
("tomato", [255, 99, 71]),
("turquoise", [64, 224, 208]),
("violet", [238, 130, 238]),
("wheat", [245, 222, 179]),
("white", [255, 255, 255]),
("whitesmoke", [245, 245, 245]),
("yellow", [255, 255, 0]),
("yellowgreen", [154, 205, 50]),
];
/// Longest name in [`NAMED`]; anything longer can't match.
const LONGEST: usize = 20;
/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so
/// `Light Blue`, `light-blue` and `lightblue` are the same colour.
pub fn lookup(s: &str) -> Option<[u8; 3]> {
let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::<String>().to_ascii_lowercase();
if key.is_empty() || key.len() > LONGEST {
return None;
}
NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_sorted_and_unique() {
assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST));
}
#[test]
fn every_name_is_found() {
for (name, rgb) in NAMED {
assert_eq!(lookup(name), Some(rgb), "{name}");
}
}
#[test]
fn lookup_normalises() {
assert_eq!(lookup("Red"), Some([255, 0, 0]));
assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237]));
assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210]));
assert_eq!(lookup(""), None);
assert_eq!(lookup("transparent"), None);
assert_eq!(lookup("reddish"), None);
assert_eq!(lookup("rédé"), None);
assert_eq!(lookup(&"a".repeat(10_000)), None);
}
}
+17
View File
@@ -17,6 +17,23 @@ fn css_parsing() {
assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]);
assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00");
assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE);
assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399");
assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT);
}
#[test]
fn colour_fields_take_names() {
assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000");
assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED");
assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]);
// Hex wins: every digit string still reads as hex.
assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC");
assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF");
// Names are opaque; `transparent` is not a field colour.
assert!(Color::from_hex_or_name("transparent").is_none());
for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] {
assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}");
}
}
#[test]
+9 -1
View File
@@ -22,10 +22,17 @@ fn colourful() -> (Document, NodeId) {
n.grids = vec![LayoutGrid { color: RED, ..LayoutGrid::default() }];
n.interactions = vec![proto::Interaction {
trigger: Default::default(),
actions: vec![proto::Action::OpenOverlay { dest: p, position: Default::default(), close_on_outside: false, background: Some(RED) }],
actions: vec![proto::Action::OpenOverlay {
dest: p,
position: Default::default(),
close_on_outside: false,
background: Some(RED),
offset: None,
}],
transition: Default::default(),
duration_ms: 300,
easing: Default::default(),
delay_ms: 0,
}];
}
if let Some(NodeKind::Text(td)) = d.node_mut(t).map(|n| &mut n.kind) {
@@ -40,6 +47,7 @@ fn colourful() -> (Document, NodeId) {
name: "Red".into(),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(RED)] },
bound: Default::default(),
});
d.variables.collections.push(Collection {
id: "c1".into(),
+29 -10
View File
@@ -106,7 +106,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
/// boolean groups are hit where their `result` is.
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
let p = doc.node(page)?;
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, false).filter(|_| !masked_out(doc, p, *c, pt)))
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt)))
}
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
@@ -129,24 +129,42 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool
/// Like [`hit_leaf`], but only within layer `root` (it included).
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
hit_rec(doc, root, pt, tol, 0, result, false)
hit_rec(doc, root, pt, tol, 0, result, None)
}
/// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit
/// where their outlines are, so a click inside one reaches the layers drawn behind it.
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
///
/// `also` is what outline mode draws besides the shapes: hidden layers are hit like the others
/// when it shows them, and every layer on the edge of its box when it shows object bounds.
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option<NodeId> {
match doc.node(root) {
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, true)),
_ => hit_rec(doc, root, pt, tol, 0, result, true),
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))),
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also)),
}
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, outline: bool) -> Option<NodeId> {
/// What outline mode shows besides the shapes' outlines (see [`hit_outlines`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OutlineHits {
pub hidden: bool,
pub bounds: bool,
}
/// Whether `pt` is within `tol` of the edge of `n`'s box.
fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> bool {
let lp = doc.world_transform(id).inverse() * pt;
let r = n.local_rect();
r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp))
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option<OutlineHits>) -> Option<NodeId> {
if depth > 512 {
return None;
}
let n = doc.node(id)?;
if !n.visible || n.locked {
let outline = also.is_some();
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked {
return None;
}
if let NodeKind::Section { contents_hidden: true } = n.kind {
@@ -164,11 +182,12 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
return None;
}
for c in n.paint_order().rev() {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, outline).filter(|_| !masked_out(doc, n, c, pt)) {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) {
return Some(h);
}
}
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) {
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) || also.is_some_and(|a| a.bounds) && on_box_edge(doc, id, n, pt, tol)
{
return Some(id);
}
None
@@ -191,7 +210,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
let at = out.len();
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
if whole {
if hit_rec(doc, id, pt, tol, depth, result, false).is_some() {
if hit_rec(doc, id, pt, tol, depth, result, None).is_some() {
out.push((id, depth));
}
return;
+7 -1
View File
@@ -7,7 +7,13 @@ use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Styl
const C3: u64 = 3 << 32;
fn style(id: &str) -> Style {
Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
Style {
id: id.into(),
name: format!("Style {id}"),
description: String::new(),
kind: StyleKind::Paint { paints: Vec::new() },
bound: Default::default(),
}
}
fn comment(id: u64, page: NodeId) -> Comment {
+67 -18
View File
@@ -21,6 +21,7 @@ pub mod styles;
pub mod text;
pub mod vector;
pub mod vector_faces;
pub mod vector_regions;
pub use colors::*;
pub use comments::*;
@@ -780,6 +781,23 @@ impl Document {
self.paste_nodes(&src_nodes, &map, id, parent, index)
}
/// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root
/// ids. One id map covers every copy, so references between the copied subtrees (a navigation
/// from one copied frame to another) point at the copies.
pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult<Vec<NodeId>> {
let mut map = BTreeMap::new();
let mut trees = Vec::with_capacity(ids.len());
for &id in ids {
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
for n in &src_nodes {
let nid = self.alloc_id();
map.insert(n.id, nid);
}
trees.push((id, src_nodes));
}
trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect()
}
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
/// subtree are remapped too.
@@ -902,15 +920,22 @@ impl Document {
/// A unique name like "Frame 3" for a new node of a kind.
pub fn next_name(&self, base: &str) -> String {
let mut max = 0u64;
let mut taken = std::collections::BTreeSet::new();
for n in self.nodes() {
if let Some(rest) = n.name.strip_prefix(base)
&& let Some(num) = rest.strip_prefix(' ')
&& let Ok(k) = num.parse::<u64>()
{
max = max.max(k);
taken.insert(k);
}
}
format!("{base} {}", max + 1)
let next = match max.checked_add(1) {
Some(next) => next,
// The counter is spent: the lowest free number instead of wrapping onto a name.
None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1),
};
format!("{base} {next}")
}
pub fn style(&self, id: &str) -> Option<&Style> {
@@ -939,7 +964,11 @@ impl Document {
if n.id != *id {
issues.push(format!("node key {id} has id {}", n.id));
}
let mut seen = std::collections::BTreeSet::new();
for c in &n.children {
if !seen.insert(*c) {
issues.push(format!("child {c} listed twice in {id}"));
}
match self.node(*c) {
Some(cn) if cn.parent == Some(*id) => {}
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
@@ -956,10 +985,32 @@ impl Document {
if n.is_page() != listed {
issues.push(format!("page list mismatch for {id}"));
}
if n.is_page() && n.parent.is_some() {
issues.push(format!("page {id} has a parent"));
}
}
let reachable = self.reachable();
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
issues.push(format!("{id} is not under a page (cycle or detached)"));
}
issues
}
/// Every node reachable from a page through reciprocal parent/child links, each once (cycles
/// and repeated children are walked once, so this is linear in the node count).
fn reachable(&self) -> std::collections::BTreeSet<NodeId> {
let mut seen = std::collections::BTreeSet::new();
let mut stack: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| n.is_page() && n.parent.is_none()).map(|(k, _)| *k).collect();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(n) = self.nodes.get(&id) else { continue };
stack.extend(n.children.iter().filter(|c| !seen.contains(*c) && self.nodes.get(*c).is_some_and(|cn| cn.parent == Some(id))));
}
seen
}
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
pub fn repair(&mut self) {
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
@@ -967,34 +1018,30 @@ impl Document {
if let Some(n) = self.nodes.get_mut(id) {
let n = Arc::make_mut(n);
n.id = *id;
// Pages are roots.
if n.is_page() {
n.parent = None;
}
}
}
// Drop dangling children and children claimed by another parent.
// Drop dangling children, children claimed by another parent and repeats.
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
for id in &ids {
let Some(n) = self.nodes.get(id) else { continue };
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
let mut seen = std::collections::BTreeSet::new();
let keep: Vec<NodeId> =
n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id) && seen.insert(*c)).collect();
if keep != n.children
&& let Some(n) = self.nodes.get_mut(id)
{
Arc::make_mut(n).children = keep;
}
}
// Orphans (parent missing or not listing them) are removed.
loop {
let orphan = self.nodes.iter().find_map(|(k, n)| {
if n.is_page() {
return None;
}
match n.parent {
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
_ => Some(*k),
}
});
let Some(o) = orphan else { break };
for d in self.subtree(o) {
self.nodes.remove(&d);
}
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
let reachable = self.reachable();
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
for k in gone {
self.nodes.remove(&k);
}
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
// don't start with their layer, are dropped.
@@ -1130,6 +1177,8 @@ mod proto_expr_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod vector_regions_tests;
#[cfg(test)]
mod width_sides_tests;
#[cfg(test)]
mod width_tests;
+16
View File
@@ -1015,6 +1015,19 @@ pub enum CrossAlign {
Baseline,
}
/// How the lines of a wrapping layout (rows of a horizontal one, columns of a vertical one) share
/// the free space across the flow.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ContentAlign {
/// Packed, `cross_gap` apart, at the start, centre or end the cross alignment names (lines of
/// nothing but fill children stretch instead).
#[default]
Auto,
/// The free space goes between the lines (one line, or no free space: like `Auto`).
SpaceBetween,
}
/// Auto layout container properties (a frame with auto layout).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -1029,6 +1042,8 @@ pub struct AutoLayout {
pub padding: [f64; 4],
pub main_align: MainAlign,
pub cross_align: CrossAlign,
/// Wrapping layouts: how the lines are spread across the flow.
pub content_align: ContentAlign,
/// Grid mode: column and row counts (rows grow to fit the children).
pub grid_columns: u32,
pub grid_rows: u32,
@@ -1052,6 +1067,7 @@ impl Default for AutoLayout {
padding: [10.0; 4],
main_align: MainAlign::Min,
cross_align: CrossAlign::Min,
content_align: ContentAlign::Auto,
grid_columns: 2,
grid_rows: 2,
column_tracks: Vec::new(),
+25
View File
@@ -534,6 +534,10 @@ pub enum Action {
/// Colour drawn behind the overlay (over the frame underneath); None = no background.
#[serde(default)]
background: Option<figcraft_color::Color>,
/// Manual position: where the overlay's top-left sits from the top-left of the layer that
/// opened it (x, y). None = right below that layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
offset: Option<[f64; 2]>,
},
SwapOverlay {
dest: NodeId,
@@ -709,12 +713,33 @@ pub struct Interaction {
pub duration_ms: u32,
#[serde(default)]
pub easing: Easing,
/// Mouse enter, leave, down and up: how long after the gesture the actions run (ms).
#[serde(default, alias = "delayMs", skip_serializing_if = "is_zero")]
pub delay_ms: u32,
}
impl Interaction {
/// Seconds between the gesture and the actions: the delay of a mouse enter, leave, down or up
/// trigger (other triggers have none; "after delay" times itself).
pub fn delay_secs(&self) -> f64 {
match self.trigger {
Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => f64::from(self.delay_ms.min(MAX_DELAY_MS)) / 1000.0,
_ => 0.0,
}
}
}
/// Longest delay a trigger waits (ms).
pub const MAX_DELAY_MS: u32 = 600_000;
fn default_duration() -> u32 {
300
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
fn is_false(b: &bool) -> bool {
!*b
}
+13 -1
View File
@@ -143,7 +143,7 @@ impl Expr {
return "…".into();
}
match self {
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::String(s)) => quote(s),
// Exact numbers and `#` colours, so the text reads back as the same expression.
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
@@ -178,6 +178,18 @@ impl Expr {
}
}
/// A variable as written in an expression: its bare name when that reads back as this variable,
/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or
/// `"q"` don't read back as literals or errors.
fn var_ref(vars: &Variables, v: &crate::Variable) -> String {
let this = Expr::var(v.id.clone());
if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) {
return v.name.clone();
}
let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id);
if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) }
}
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
/// spaces at its ends, quotes or brackets.
fn name_arg(name: &str) -> String {
+19
View File
@@ -176,3 +176,22 @@ fn hostile_text_is_bounded() {
}
}
}
#[test]
fn names_that_look_like_literals_read_back_as_variables() {
for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] {
let mut v = vars();
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") {
var.name = name.into();
}
for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
}
}
// Plain names stay bare, and literals still read as literals.
let v = vars();
assert_eq!(Expr::var("n").display(&v), "n-name");
assert_eq!(parse("1"), n(1.0));
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
}
+6
View File
@@ -40,6 +40,12 @@ pub struct Style {
pub description: String,
#[serde(flatten)]
pub kind: StyleKind,
/// Variables bound to fields of the value, by the keys a layer's bindings use
/// (`fills/0/color`, `effects/0/radius`, `layoutGrids/0/size`, a text style's `fontSize`…).
/// A layer using the style sees them resolved in its own modes; `kind` holds them resolved in
/// the default modes.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bound: BTreeMap<String, VariableId>,
}
/// A node's links to shared styles.
+118
View File
@@ -97,6 +97,7 @@ fn duplicate_remaps_ids_and_interactions() {
transition: proto::Transition::Instant,
duration_ms: 300,
easing: proto::Easing::EaseOut,
delay_ms: 0,
});
let f2 = d.duplicate_into(f, p, None).unwrap();
let kids = d.node(f2).unwrap().children.clone();
@@ -167,6 +168,27 @@ fn text_runs_restyle_and_replace() {
assert_eq!(t.characters, "");
}
/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load)
/// must not overflow the arithmetic that shifts the runs along, and must not leave the
/// text unwritable.
#[test]
fn text_runs_past_the_text_never_overflow() {
let style = || TextStyle { font_style: "Bold".into(), ..Default::default() };
for end in [usize::MAX, usize::MAX - 1, 1_000_000] {
let mut t = TextData::new("hi");
t.runs = vec![StyleRun { start: 0, end, style: style() }];
// Typing at the caret, and replacing a selection: both shift `end`.
t.replace_range(0, 0, "x");
assert_eq!(t.characters, "xhi", "end {end}");
t.replace_range(0, 3, "yo");
assert_eq!(t.characters, "yo", "end {end}");
// The text is still writable and the runs are back inside it.
t.replace_range(0, 0, "!");
assert_eq!(t.characters, "!yo", "end {end}");
assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs);
}
}
#[test]
fn text_case() {
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
@@ -280,6 +302,30 @@ fn serde_roundtrip_and_repair() {
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
}
#[test]
fn repair_drops_cycles_and_repeated_children() {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
let mut v = serde_json::to_value(&d).unwrap();
// F and G each other's parent and child (G listing F twice), and R listed twice by the page.
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]);
v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r));
let mut bad: Document = serde_json::from_value(v).unwrap();
let issues = bad.validate();
for want in ["listed twice", "not under a page"] {
assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}");
}
bad.repair();
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
assert!(bad.node(f).is_none() && bad.node(g).is_none());
assert_eq!(bad.node(p).unwrap().children, vec![r]);
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn auto_layout_loads_files_without_grid_tracks() {
// Saved before grid tracks, cells and spans existed.
@@ -310,6 +356,16 @@ fn next_name_increments() {
assert_eq!(d.next_name("Ellipse"), "Ellipse 1");
}
#[test]
fn next_name_never_overflows() {
let mut d = Document::new("x");
let p = page(&d);
d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap();
assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX");
d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap();
assert_eq!(d.next_name("Frame"), "Frame 2");
}
#[test]
fn custom_easings() {
use crate::proto::Easing;
@@ -346,3 +402,65 @@ fn custom_easings() {
assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 });
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
}
#[test]
fn text_style_updates_merge_equal_runs() {
let def = TextStyle { font_size: 14.0, ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(0, 2, &def, "S");
t.link_text_style(2, 4, &def, "S");
t.restyle(2, 4, |s| s.font_size = 24.0);
assert_eq!(t.runs.len(), 2);
t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() });
let spans = t.spans();
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].0, spans[0].1), (0, 4));
assert_eq!(spans[0].2.font_size, 24.0);
assert_eq!(spans[0].2.style_id.as_deref(), Some("S"));
// Remapping two links to one id merges the runs.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &def, "B");
t.map_text_styles(|_| Some("C".into()));
assert_eq!(t.spans().len(), 1);
assert_eq!(t.style_at(1).style_id.as_deref(), Some("C"));
// Different values stay separate.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B");
t.map_text_styles(|_| Some("C".into()));
let spans = t.spans();
assert_eq!(spans.len(), 2);
assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0));
// Unlinking a run equal to the base drops it.
let mut t = TextData::new("ab");
let base = t.style.clone();
t.link_text_style(0, 1, &base, "S");
t.map_text_styles(|_| None);
assert!(t.runs.is_empty());
}
#[test]
fn text_style_empty_range_links_and_unlinks_all() {
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(2, 2, &def, "S");
assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold"));
t.unlink_text_style(2, 2);
assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold"));
// Partial ranges stay partial.
t.link_text_style(0, 4, &def, "S");
t.unlink_text_style(1, 3);
assert_eq!(t.style_at(0).style_id.as_deref(), Some("S"));
assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none());
assert_eq!(t.style_at(3).style_id.as_deref(), Some("S"));
// Empty text changes the base style.
let mut e = TextData::new("");
e.link_text_style(0, 0, &def, "S");
assert_eq!(e.style.style_id.as_deref(), Some("S"));
e.unlink_text_style(0, 0);
assert!(e.style.style_id.is_none());
}
+58 -10
View File
@@ -215,6 +215,10 @@ pub struct ParaList {
pub list: ListStyle,
/// Nesting level (0 = outermost; plain paragraphs are always 0).
pub indent: u8,
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
/// (items that go on counting ignore it; other paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start_offset: u32,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -562,7 +566,7 @@ impl TextData {
}
}
/// Drop empty/out-of-range runs and runs equal to the base style.
/// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs.
pub fn normalize(&mut self) {
let n = self.char_count();
let base = self.style.clone();
@@ -571,6 +575,14 @@ impl TextData {
r.end = r.end.min(n);
}
self.runs.sort_by_key(|r| r.start);
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
for r in std::mem::take(&mut self.runs) {
match merged.last_mut() {
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
_ => merged.push(r),
}
}
self.runs = merged;
}
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
@@ -602,7 +614,9 @@ impl TextData {
if p <= start {
p
} else if p >= end {
p - removed + inserted
// Runs of a file that reach past the text (nothing clamps them on load)
// would otherwise overflow here; `normalize` below drops what is left.
p.saturating_sub(removed).saturating_add(inserted)
} else {
start + inserted
}
@@ -627,13 +641,13 @@ impl TextData {
/// Paragraph `p`'s list settings.
pub fn para_list(&self, p: usize) -> ParaList {
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
}
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
fn fill_paragraphs(&mut self) {
let n = self.paragraph_count();
let fill = ParaList { list: self.list, indent: 0 };
let fill = ParaList { list: self.list, ..ParaList::default() };
self.paragraphs.resize(n, fill);
}
@@ -647,17 +661,23 @@ impl TextData {
}
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
/// no nesting folds back into `list`.
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
let (a, b) = self.para_span(start, end);
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
f(pl);
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
if pl.list == ListStyle::None {
pl.indent = 0;
}
if pl.list != ListStyle::Numbered {
pl.start_offset = 0;
}
pl.indent = pl.indent.min(MAX_LIST_INDENT);
}
if let Some(first) = self.paragraphs.first().copied()
&& first.indent == 0
&& first == (ParaList { list: first.list, ..ParaList::default() })
&& self.paragraphs.iter().all(|p| *p == first)
{
self.list = first.list;
@@ -665,21 +685,24 @@ impl TextData {
}
}
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
/// one's settings), or None when they differ. Start numbers don't count.
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
let (a, b) = self.para_span(start, end);
let first = self.para_list(a);
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
/// style `id`, defined as `def`.
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.take_text_style(def, id));
}
/// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay.
pub fn unlink_text_style(&mut self, start: usize, end: usize) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.style_id = None);
}
@@ -770,4 +793,29 @@ mod case_tests {
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
}
#[test]
fn list_starts_load_from_old_files_and_follow_edits() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start_offset, 1);
t.set_para_lists(5, 5, |p| p.start_offset = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// Bullets have no number, so no start.
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
}
}
+3 -3
View File
@@ -468,9 +468,9 @@ impl VectorNetwork {
start_dir(&self.outward(seg, at_a)?)
}
/// Whether any point has a stroke cap or join of its own.
pub fn has_point_styles(&self) -> bool {
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some())
/// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`.
pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool {
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join))
}
/// The fill path: every region's loops.
+19 -2
View File
@@ -136,8 +136,8 @@ impl VectorNetwork {
}
let k = key(&face.loops);
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
(Some(i), None | Some(false)) => {
self.regions.remove(i);
(Some(_), None | Some(false)) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
(Some(_), Some(true)) => Some(true),
@@ -525,4 +525,21 @@ mod tests {
let _ = j.paint(Point::new(f64::NAN, 5.0));
assert!(interior_point(&BezPath::new()).is_none());
}
#[test]
fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() {
let c = Point::new(50.0, 50.0);
let mut n = square(0.0, 0.0, 100.0);
n.regions.push(n.regions[0].clone());
assert_eq!(n.paint(c), Some(false));
assert!(!n.fill_contains(c));
assert_eq!(n.region_at(c), None);
assert_eq!(n.paint(c), Some(true));
assert!(n.fill_contains(c));
let mut e = square(0.0, 0.0, 100.0);
e.regions.push(e.regions[0].clone());
assert_eq!(e.paint_to(c, Some(false)), Some(false));
assert!(!e.fill_contains(c));
}
}
+139
View File
@@ -0,0 +1,139 @@
//! A vector network's regions as areas: walking a region's loops into contours, whether a point is
//! inside a region by that region's own fill rule, which region is under a point (the last one
//! listed wins) and a region's points. Vector edit mode hovers, hatches and selects regions with
//! these.
//!
//! A loop lists its segments in ring order, but each segment may be stored either way round: the
//! walk follows the point reached so far and runs a segment backwards when it starts at its `b` end.
use std::hash::{Hash, Hasher};
use figcraft_geom::{BezPath, Point, Shape};
use crate::vector::{VectorNetwork, WindingRule};
impl VectorNetwork {
/// Loop `lp` walked as a ring: each existing segment with whether it is walked backwards (from
/// its `b` end to its `a` end). The first segment runs toward the point it shares with the
/// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken
/// as stored. Missing segments are skipped.
pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> {
let mut existing = lp.iter().filter_map(|i| self.segments.get(*i));
let Some(first) = existing.next() else { return Vec::new() };
let forward_first = match existing.next() {
Some(second) => first.b == second.a || first.b == second.b,
None => true,
};
let mut cur = if forward_first { first.a } else { first.b };
let mut out = Vec::with_capacity(lp.len());
for &i in lp {
let Some(s) = self.segments.get(i) else { continue };
if s.a == cur {
out.push((i, false));
cur = s.b;
} else if s.b == cur {
out.push((i, true));
cur = s.a;
} else {
out.push((i, false));
cur = s.b;
}
}
out
}
/// Region `r`'s outline: each loop one closed contour, curves kept as curves (segments or
/// points that don't exist are left out). `None` when there is no such region or nothing of it
/// remains.
pub fn region_path(&self, r: usize) -> Option<BezPath> {
let region = self.regions.get(r)?;
let mut path = BezPath::new();
for lp in &region.loops {
let mut open = false;
let mut at = Point::ZERO;
for (i, back) in self.loop_walk(lp) {
let Some(s) = self.segments.get(i) else { continue };
let (Some(a), Some(b)) = (self.vertices.get(s.a), self.vertices.get(s.b)) else { continue };
let (a, b) = (a.point(), b.point());
let (c1, c2) = (Point::new(a.x + s.ta.0, a.y + s.ta.1), Point::new(b.x + s.tb.0, b.y + s.tb.1));
let (from, c1, c2, to) = if back { (b, c2, c1, a) } else { (a, c1, c2, b) };
if !open {
path.move_to(from);
open = true;
} else if from != at {
// A gap in a malformed loop: bridged with a straight line.
path.line_to(from);
}
if s.is_straight() {
path.line_to(to);
} else {
path.curve_to(c1, c2, to);
}
at = to;
}
if open {
path.close_path();
}
}
(!path.elements().is_empty()).then_some(path)
}
/// The winding count of `p` about all of region `r`'s loops together (curves counted where
/// they run). `None` when there is no such region.
pub fn region_winding(&self, r: usize, p: Point) -> Option<i32> {
self.regions.get(r)?;
Some(self.region_path(r).map_or(0, |path| path.winding(p)))
}
/// Whether `p` is inside region `r` by the region's own fill rule (non-zero: any winding;
/// even-odd: an odd winding).
pub fn region_contains(&self, r: usize, p: Point) -> bool {
let (Some(region), Some(w)) = (self.regions.get(r), self.region_winding(r, p)) else { return false };
match region.winding {
WindingRule::NonZero => w != 0,
WindingRule::EvenOdd => w % 2 != 0,
}
}
/// The region under `p`: the last one listed that contains it.
pub fn region_at(&self, p: Point) -> Option<usize> {
if !(p.x.is_finite() && p.y.is_finite()) {
return None;
}
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
}
/// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that
/// don't exist are left out).
pub fn region_points(&self, r: usize) -> Vec<usize> {
let Some(region) = self.regions.get(r) else { return Vec::new() };
let mut out: Vec<usize> = region
.loops
.iter()
.flatten()
.filter_map(|i| self.segments.get(*i))
.flat_map(|s| [s.a, s.b])
.filter(|v| *v < self.vertices.len())
.collect();
out.sort_unstable();
out.dedup();
out
}
/// A fingerprint of the network's topology: which points each segment joins and which
/// segments each region's loops list. Moving points or handles keeps it; adding, removing or
/// reordering segments, regions or loops changes it (region indexes are only good while it
/// stays the same).
pub fn topology_key(&self) -> u64 {
let mut h = std::hash::DefaultHasher::new();
self.segments.len().hash(&mut h);
for s in &self.segments {
(s.a, s.b).hash(&mut h);
}
self.regions.len().hash(&mut h);
for r in &self.regions {
r.loops.hash(&mut h);
}
h.finish()
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Regions as areas (`vector_regions.rs`): the inside test by each region's own fill rule, the last
//! region winning where regions overlap, curved edges, loops with segments stored backwards, a
//! region's points and empty or damaged networks.
use figcraft_geom::Point;
use crate::vector::{Region, Segment, VectorNetwork, Vertex, WindingRule};
/// Four points and four straight segments of a square at (`x0`, `y0`), clockwise on screen (y
/// down), appended to `n`. Returns the segment indexes in ring order.
fn square(n: &mut VectorNetwork, x0: f64, y0: f64, size: f64) -> Vec<usize> {
let v0 = n.vertices.len();
for (x, y) in [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)] {
n.vertices.push(Vertex::new(x, y));
}
let s0 = n.segments.len();
for k in 0..4 {
n.segments.push(Segment::line(v0 + k, v0 + (k + 1) % 4));
}
(s0..s0 + 4).collect()
}
/// The 7.1 network: a 200 square (points 0..3, segments 0..3) with a 80 square inside it (points
/// 4..7, segments 4..7), both wound the same way, and no regions yet.
fn nested() -> VectorNetwork {
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
square(&mut n, 60.0, 60.0, 80.0);
n
}
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
Region { loops, winding }
}
fn p(x: f64, y: f64) -> Point {
Point::new(x, y)
}
#[test]
fn the_inside_test_follows_each_regions_own_fill_rule() {
let mut n = nested();
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
// 1. Non-zero: the centre counts (both rings wound the same way).
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
// 4. Outside.
assert_eq!(n.region_at(p(300.0, 100.0)), None);
n.regions[0].winding = WindingRule::EvenOdd;
// 2. Even-odd: the centre is a hole; 3. the ring is inside.
assert_eq!(n.region_at(p(100.0, 100.0)), None);
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
assert_eq!(n.region_at(p(300.0, 100.0)), None);
// 5. The inner ring wound the other way, non-zero: a hole.
let mut r = nested();
for s in &mut r.segments[4..8] {
std::mem::swap(&mut s.a, &mut s.b);
}
r.regions = vec![region(vec![vec![0, 1, 2, 3], vec![7, 6, 5, 4]], WindingRule::NonZero)];
assert_eq!(r.region_at(p(100.0, 100.0)), None);
assert_eq!(r.region_at(p(20.0, 100.0)), Some(0));
// Regions of one path may use different rules.
let mut m = nested();
m.regions = vec![
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero),
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::EvenOdd),
];
assert_eq!(m.region_at(p(100.0, 100.0)), Some(0), "the even-odd region has a hole there, the non-zero one doesn't");
assert_eq!(m.region_at(p(20.0, 100.0)), Some(1));
}
#[test]
fn the_last_overlapping_region_wins() {
let mut n = nested();
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
// 6, 7.
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
// 8. Swapped: the outer square (region 0 before, listed last now) wins in the middle.
n.regions.swap(0, 1);
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
assert_eq!(n.region_points(1), vec![0, 1, 2, 3], "index 1 is the outer square now");
assert_eq!(n.region_at(p(20.0, 100.0)), Some(1));
}
#[test]
fn curved_edges_count_where_the_curve_runs() {
let mut n = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(120.0, 0.0)],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -80.0), tb: (0.0, -80.0) }, Segment::line(1, 0)],
regions: vec![region(vec![vec![0, 1]], WindingRule::NonZero)],
};
// 9. Inside the bulge, above the chord; 10. below the chord; 11. above the curve's top (-60).
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
assert_eq!(n.region_at(p(60.0, 20.0)), None);
assert_eq!(n.region_at(p(60.0, -70.0)), None);
assert_eq!(n.region_at(p(60.0, -58.0)), Some(0), "just under the top of the curve");
// The lens walks toward the point it shares with the second segment, whichever way that is.
n.segments.swap(0, 1);
n.regions = vec![region(vec![vec![0, 1]], WindingRule::NonZero)];
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
assert_eq!(n.region_at(p(60.0, -70.0)), None);
}
#[test]
fn a_single_curved_segment_loop_encloses_its_area() {
let n = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0)],
segments: vec![Segment { a: 0, b: 0, ta: (100.0, -100.0), tb: (-100.0, -100.0) }],
regions: vec![region(vec![vec![0]], WindingRule::NonZero)],
};
assert_eq!(n.region_at(p(0.0, -40.0)), Some(0));
assert_eq!(n.region_at(p(0.0, 40.0)), None);
}
#[test]
fn segments_stored_backwards_are_walked_the_right_way() {
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
// Segment 1 stored as 2 → 1 and segment 3 as 0 → 3.
n.segments[1] = Segment::line(2, 1);
n.segments[3] = Segment::line(0, 3);
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
// 12, 13.
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
assert_eq!(n.region_at(p(300.0, 100.0)), None);
let walked = n.loop_walk(&[0, 1, 2, 3]);
assert_eq!(walked, vec![(0, false), (1, true), (2, false), (3, true)]);
// 14. The first segment reversed too: unchanged.
n.segments[0] = Segment::line(1, 0);
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
assert_eq!(n.region_at(p(300.0, 100.0)), None);
// The walk equals the same loop stored consistently: one closed contour of the same area.
use figcraft_geom::Shape;
let mut consistent = VectorNetwork::default();
square(&mut consistent, 0.0, 0.0, 200.0);
consistent.regions = n.regions.clone();
let (a, b) = (n.region_path(0).unwrap().area().abs(), consistent.region_path(0).unwrap().area().abs());
assert!((a - 40_000.0).abs() < 1e-6 && (b - 40_000.0).abs() < 1e-6, "{a} {b}");
}
#[test]
fn a_loop_that_does_not_close_is_closed_implicitly() {
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
// The walk ends at point 3 and closes straight back to point 0.
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
assert_eq!(n.region_at(p(300.0, 100.0)), None);
}
#[test]
fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() {
use figcraft_geom::Shape;
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
let control = n.region_path(0).unwrap();
let control_walk = n.loop_walk(&[0, 1, 2]);
for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] {
n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)];
assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}");
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}");
assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}");
let path = n.region_path(0).unwrap();
assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}");
assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}");
}
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_path(0).is_none());
assert!(!n.region_contains(0, p(100.0, 100.0)));
}
#[test]
fn a_regions_points_are_listed_once() {
let mut n = nested();
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
// 15.
assert_eq!(n.region_points(0), vec![0, 1, 2, 3, 4, 5, 6, 7]);
// 16.
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
assert_eq!(n.region_points(1), vec![4, 5, 6, 7]);
assert!(n.region_points(9).is_empty());
// 17. A missing segment is ignored.
n.regions = vec![region(vec![vec![0, 1, 99, 2, 3]], WindingRule::NonZero)];
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
}
#[test]
fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
// 18.
let mut n = nested();
assert_eq!(n.region_at(p(100.0, 100.0)), None);
let empty = VectorNetwork::default();
assert_eq!(empty.region_at(p(0.0, 0.0)), None);
assert!(empty.region_path(0).is_none());
assert!(empty.region_points(0).is_empty());
assert_eq!(empty.region_winding(0, p(0.0, 0.0)), None);
// Loops naming missing segments, segments naming missing points, empty loops, junk points.
n.regions = vec![region(vec![vec![], vec![42, 43]], WindingRule::EvenOdd), region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
n.segments.push(Segment::line(1, 500));
n.regions.push(region(vec![vec![8, 8, 8]], WindingRule::NonZero));
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
assert!(n.region_path(0).is_none());
assert!(n.region_path(2).is_none());
for q in [p(f64::NAN, 0.0), p(f64::INFINITY, 1.0), p(1e300, -1e300)] {
let _ = n.region_at(q);
}
}
#[test]
fn region_points_leave_out_segment_ends_that_do_not_exist() {
let mut n = VectorNetwork::default();
let ring = square(&mut n, 0.0, 0.0, 100.0);
n.regions = vec![region(vec![ring], WindingRule::NonZero)];
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
n.segments[3] = Segment::line(3, 9);
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
assert!(n.region_path(0).is_some());
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_points(0).is_empty());
}
#[test]
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
let mut n = nested();
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
let k = n.topology_key();
n.translate(15.0, 25.0);
n.segments[0].ta = (5.0, 5.0);
assert_eq!(n.topology_key(), k, "moving points and handles keeps it");
n.regions.push(region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero));
assert_ne!(n.topology_key(), k);
let k = n.topology_key();
n.regions.swap(0, 1);
assert_ne!(n.topology_key(), k, "reordered regions");
let k = n.topology_key();
n.segments.push(Segment::line(0, 2));
assert_ne!(n.topology_key(), k);
}
+115
View File
@@ -0,0 +1,115 @@
//! Wrapped-line alignment through commands: the `contentAlign` prop and its alias, undo, the CSS
//! and inspect output, instance overrides, the REST and `.fig` mappings, and junk values.
use serde_json::{Value, json};
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::{ContentAlign, NodeId};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// A 100 × 100 wrapping frame of three 40 × 20 rectangles (gap 10, row gap 5): two rows.
fn wrapping(s: &mut Session) -> (NodeId, Vec<NodeId>) {
let props = json!({"layout": "horizontal", "wrap": true, "padding": 0, "gap": 10, "crossGap": 5});
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": props})).unwrap());
let kids = (0..3).map(|_| id(s.execute("shape.rectangle", &json!({"parent": f, "x": 0, "y": 0, "width": 40, "height": 20})).unwrap())).collect();
(f, kids)
}
fn ys(s: &Session, kids: &[NodeId]) -> Vec<f64> {
kids.iter().map(|k| s.doc().unwrap().node(*k).unwrap().transform.as_coeffs()[5]).collect()
}
fn content(s: &Session, f: NodeId) -> ContentAlign {
s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().content_align
}
#[test]
fn content_align_spreads_the_rows_and_undoes() {
let mut s = Session::new();
let (f, kids) = wrapping(&mut s);
assert_eq!(ys(&s, &kids), [0.0, 0.0, 25.0]);
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
assert_eq!(ys(&s, &kids), [0.0, 0.0, 80.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((content(&s, f), ys(&s, &kids)), (ContentAlign::Auto, vec![0.0, 0.0, 25.0]));
// The file format's name and spelling work too; packed rows follow the cross alignment.
s.execute("node.set", &json!({"id": f, "props": {"counterAxisAlignContent": "SPACE_BETWEEN"}})).unwrap();
assert_eq!(content(&s, f), ContentAlign::SpaceBetween);
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "auto", "crossAlign": "max"}})).unwrap();
assert_eq!(ys(&s, &kids), [55.0, 55.0, 80.0]);
}
#[test]
fn css_and_inspect_name_the_lines() {
let mut s = Session::new();
let (f, _) = wrapping(&mut s);
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": f})).unwrap().to_string();
assert!(css(&mut s).contains("align-content: flex-start;"), "{}", css(&mut s));
s.execute("node.set", &json!({"id": f, "props": {"crossAlign": "center"}})).unwrap();
assert!(css(&mut s).contains("align-content: center;"));
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
assert!(css(&mut s).contains("align-content: space-between;"));
let rows = s.execute("inspect.properties", &json!({"id": f})).unwrap().to_string();
assert!(rows.contains("Auto (space between)"), "{rows}");
// One line has no lines to align.
s.execute("node.set", &json!({"id": f, "props": {"wrap": false}})).unwrap();
assert!(!css(&mut s).contains("align-content"));
}
#[test]
fn an_instance_keeps_its_own_line_alignment() {
let mut s = Session::new();
let (f, _) = wrapping(&mut s);
let comp = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
let inst = id(s.execute("instance.create", &json!({"component": comp, "x": 200, "y": 0})).unwrap());
s.execute("node.set", &json!({"id": inst, "props": {"contentAlign": "spaceBetween"}})).unwrap();
// A change to the main reaches the instance; its own alignment stays.
s.execute("node.set", &json!({"id": comp, "props": {"padding": 2}})).unwrap();
assert_eq!((content(&s, comp), content(&s, inst)), (ContentAlign::Auto, ContentAlign::SpaceBetween));
}
#[test]
fn rest_and_fig_carry_the_setting() {
let frame = |extra: Value| {
let mut f = json!({"id": "1:2", "name": "Wrap", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 100, "y": 100},
"layoutMode": "HORIZONTAL", "layoutWrap": "WRAP", "children": []});
f.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
json!({"name": "File", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [f]}]}})
};
let read = |file: &Value| {
let (doc, _) = figcraft_format::rest::import(file, &figcraft_format::rest::no_images).unwrap();
doc
};
let of = |doc: &figcraft_doc::Document| doc.nodes().find(|n| n.name == "Wrap").unwrap().layout.as_ref().unwrap().content_align;
assert_eq!(of(&read(&frame(json!({})))), ContentAlign::Auto);
assert_eq!(of(&read(&frame(json!({"counterAxisAlignContent": "AUTO"})))), ContentAlign::Auto);
let spread = read(&frame(json!({"counterAxisAlignContent": "SPACE_BETWEEN"})));
assert_eq!(of(&spread), ContentAlign::SpaceBetween);
// Written to `.fig` and read back, both values survive.
for doc in [spread, read(&frame(json!({})))] {
let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
assert_eq!(of(&back), of(&doc));
}
}
#[test]
fn junk_values_are_refused_not_fatal() {
let junk =
[json!(null), json!(true), json!(-1), json!(1e308), json!(""), json!("sideways"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))];
for k in ["contentAlign", "counterAxisAlignContent"] {
for j in &junk {
let mut s = Session::new();
let (f, kids) = wrapping(&mut s);
for target in [f, kids[0]] {
let r = s.execute("node.set", &json!({"id": target, "props": {k: j}}));
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{k} = {j}: {r:?}");
}
assert_eq!(content(&s, f), ContentAlign::Auto, "{k} = {j}");
}
}
}
+21
View File
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
}
#[test]
fn single_item_wrapped_lines_move_and_drop_by_line() {
let mut s = Session::new();
// Full-width items: one per row.
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
set(&mut s, row, json!({"wrap": true}));
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
arrow(&mut s, &[k[1]], 0, -1);
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
// Full-height items in a vertical wrap: one per column.
let col = al(&mut s, None, "vertical", 200.0, 160.0);
set(&mut s, col, json!({"wrap": true}));
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
arrow(&mut s, &[c[2]], -1, 0);
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
}
#[test]
fn wrapped_rows_move_up_and_down_a_row() {
let mut s = Session::new();
+49
View File
@@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() {
assert!(!autosave(&mut t));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn files_that_save_themselves_still_get_checkpoints() {
let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("pref.autoSave", &json!({"on": true})).unwrap();
rect(&mut s, 0.0);
s.execute("file.save", &json!({"path": path})).unwrap();
assert!(autosave(&mut s), "saved, but changed since the last version");
assert!(!autosave(&mut s), "no change since the checkpoint");
// Just opened: nothing to checkpoint.
let mut t = Session::empty();
t.execute("pref.autoSave", &json!({"on": true})).unwrap();
t.execute("file.open", &json!({"path": path})).unwrap();
assert!(!autosave(&mut t));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() {
use crate::cmd::file::FileSnapshot;
let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("snap.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("file.save", &json!({"path": path})).unwrap();
rect(&mut s, 0.0);
let file = FileSnapshot::of(s.ds().unwrap());
let bytes = file.encode().unwrap();
rect(&mut s, 50.0);
assert!(file.save(&mut s, &bytes).unwrap());
assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved");
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot");
// Saved meanwhile: the older bytes never overwrite the newer file.
let file = FileSnapshot::of(s.ds().unwrap());
let bytes = file.encode().unwrap();
rect(&mut s, 100.0);
s.execute("file.save", &json!({})).unwrap();
assert!(!file.save(&mut s, &bytes).unwrap());
assert!(!s.ds().unwrap().is_dirty());
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0);
let _ = std::fs::remove_dir_all(&dir);
}
+8 -7
View File
@@ -32,17 +32,17 @@ struct Wire {
struct Link(Arc<Mutex<Wire>>);
impl LiveLink for Link {
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
Err("no".into())
}
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
Err("no".into())
}
fn leave(&mut self) {}
fn presence(&mut self, _: &Presence) {}
fn send(&mut self, _: ClientMsg) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.0.lock().unwrap().answers)
fn leave(&mut self, _: u64) {}
fn presence(&mut self, _: u64, _: &Presence) {}
fn send(&mut self, _: u64, _: ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, Event)> {
std::mem::take(&mut self.0.lock().unwrap().answers).into_iter().map(|e| (0, e)).collect()
}
fn plugins(&mut self, _: &str, req: PluginReq) -> Result<PluginReply, String> {
self.0.lock().unwrap().waited += 1;
@@ -112,6 +112,7 @@ fn answer(s: &mut Session, wire: &Arc<Mutex<Wire>>) -> usize {
NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))),
NetRequest::Upload(_) => NetReply::Upload(Err("no".into())),
NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())),
NetRequest::Share { .. } => NetReply::Share(Err("no".into())),
};
w.answers.push(Event::Net { job: *job, reply: Box::new(reply) });
}
+85
View File
@@ -0,0 +1,85 @@
use serde_json::json;
use crate::plugins_tests::{OnRefusal, install, manifest, script};
use crate::{EngineError, Session};
fn id(value: &serde_json::Value) -> crate::NodeId {
crate::params::id_of(&value["id"]).unwrap()
}
#[test]
fn failed_group_closes_documents_opened_inside_it() {
let mut s = Session::new();
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let before_journal = s.journal.clone();
let before_history = s.ds().unwrap().history.undo.len();
let result = s.execute(
"command.batch",
&json!({
"label": "Open then fail",
"commands": [
{"command": "file.new", "params": {"name": "Temporary"}},
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.docs.len(), 1);
assert_eq!(s.active, 0);
assert!(s.doc().unwrap().contains(original));
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
assert_eq!(s.journal, before_journal);
}
#[test]
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let before_history: Vec<_> = s.docs.iter().map(|ds| ds.history.undo.len()).collect();
let calls = [
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})),
("doc.nextTab", json!({})),
("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})),
("node.set", json!({"id": 999999, "props": {"name": "must fail"}})),
];
install(&mut s, manifest("org.test.rollback", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
s.message = Some("keep this".into());
let before_journal = s.journal.clone();
let before_message = s.message.clone();
// The new file is the one in front when the plugin starts.
let before_active = s.active;
let result = s.execute("plugin.run", &json!({"id": "org.test.rollback"}));
assert!(matches!(result, Err(EngineError::Other(_))));
assert_eq!(s.docs.iter().map(|ds| ds.doc.node(ds.page).map_or(0, |page| page.children.len())).collect::<Vec<_>>(), vec![0, 0]);
assert_eq!(s.active, before_active, "the tab in front is put back");
assert_eq!(s.docs.iter().map(|ds| ds.history.undo.len()).collect::<Vec<_>>(), before_history);
assert_eq!(s.journal, before_journal);
assert_eq!(s.message, before_message);
}
#[test]
fn failed_group_keeps_the_redo_steps() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(s.undo().unwrap());
assert_eq!(s.undo_depth().1, 1);
let result = s.execute(
"command.batch",
&json!({
"commands": [
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch");
assert!(s.redo().unwrap());
}
Loaded 100 of 475 files, more files were not shown because too many files have changed in this diff. Show more