Compare commits

...

120 Commits

Author SHA1 Message Date
Nathan Fontenele 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 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 10:32:44 +07:00
Nathan Fontenele 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 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 10:32:30 +07:00
Justin John 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Not in this PR
- Documented but not modelled yet: list spacing, hanging quotes and hanging lists, underline style / thickness / offset / skip ink.
- Documentation silent, behaviour pinned: Korean wraps by syllable (no word mode); Han compounds next to each other are one word (no dictionary segmentation); letter spacing is added after the last character too; a fallback font's taller glyphs may overflow the line rather than grow it.
- `.fig` import doesn't read vertical trim yet (the field wasn't checked against real files).
- Spell check (on hold, #113).
- Progress docs: target-app-parity area 10 85%/64% → 87%/68%, 7–13 h → 5–10 h with the suite listed under text editing depth; feature breadth and ready for real work unchanged at this rounding; full parity ~323–621 h → ~321–618 h (target-app-parity, ROADMAP); ui-parity text row.
2026-10-11 22:09:46 +07:00
BFlat 894f25c4c4
M1: selection and transform behaviour suite and fixes (#133)
A selection and transform behaviour suite derived from public documentation, with fixes: Alt-resizing shrinks as well as grows; groups, Scale-constrained frames and multiple selections resize each layer along its own axes keeping rotation and flip; several layers flip together past an edge; a flipped layer reads back the typed rotation; resizes land on whole pixels with the pixel grid; presses inside tiny layers move them; locked layers get no handles; a marquee can start on a top-level frame's background; select inverse skips hidden layers. Adds equal-spacing snapping with labelled gaps and Cmd/Ctrl to ignore constraints while resizing a frame.
2026-10-11 20:30:38 +07:00
BFlat 2f49fcc490
M6: .fig import (best effort) (#132)
.fig files open through file.open, file.import, drag and drop, the dialogs, the CLI and MCP. A generic reader written from the public format description decodes the zip, the deflate/zstd chunks and the kiwi schema and message, then maps nodes by field name onto the REST importer: layer tree, geometry, paints with styles resolved, strokes, effects, text runs, vectors as outlines, auto layout including grid, constraints, components, sets, instances expanded from their main with overrides, styles, variables with modes and bindings, prototyping and image fills. Unsupported layers come back as warnings. Fuzzed; tested on fixtures generated in code. New dependency: ruzstd (pure-Rust zstd decoder).
2026-10-11 18:59:59 +07:00
BFlat 10a9b7b165
M8: Windows QA fixes (#131)
Found and fixed in a Windows pass of everyday workflows: unsaved documents are now copied in the background (at most every 10 s) and offered back after a crash or forced quit (file.recover); Ctrl+arrow / Ctrl+Backspace / Ctrl+Delete text motion follows Windows and Linux conventions (keymap::text_motion); number-field scrubbing keeps its value; typing in a Tab-focused field works; default linear gradients and their handles behave; Escape closes dialogs without deselecting; clipped panel hints wrap; the unsaved-changes dialog is translated.
2026-10-11 18:20:38 +07:00
BFlat d01d18cd88
M2: auto layout behaviour suite and fixes (#130)
A 64-case behaviour suite derived from public documentation (sizing, padding, strokes, gaps, alignment, wrap, absolute position, grid, stacking) plus command-level tests. Fixes: frames never shrink below their padding, only the frame's own inside stroke counts when strokes are included (now the default for new auto layout), auto gaps stop at 0, grid fr tracks never go below their content, and canvas stacking (reverseZ) now drives drawing, exports and hit testing. New settings: evenly/around auto gaps, vertical wrap with a column gap, per-child grid cell alignment, strokes included/excluded and canvas stacking in the Design panel.
2026-10-11 17:53:11 +07:00
BFlat 24e457240e
M5: Display P3: gamut warning and soft-proof sRGB (#127)
True wide-gamut display is blocked upstream (egui-wgpu 0.36 always picks an 8-bit sRGB surface and draws sRGB-encoded UI), so the colour picker warns when a Display P3 colour is outside sRGB and View > Soft-proof sRGB stripes the canvas pixels sRGB can't show. Exports keep wide-gamut colours as before.
2026-10-11 17:20:10 +07:00
BFlat f49142dad9
M1: several windows (#126)
File > New window, Open in new window on a tab, and dragging a tab out open files in separate OS windows (egui deferred viewports, redrawn only when needed), sharing one session, preferences, libraries and clipboard. Closing a window asks about unsaved files (the main window now asks too); closing the last window quits. window.new / window.list / window.focus and per-window screenshots for the control channel and attached MCP. The browser keeps one window.
2026-10-11 16:50:06 +07:00
BFlat a2e2012fdc
M7: localization layer (#128)
UI strings come from per-language TOML catalogs compiled in (English text as the key and fallback, named placeholders, one/other plurals, context suffixes); command labels are translated at display time while ids, CLI, MCP and control output stay English; accessibility names follow. Preferences > Language (System / English / Español, live; the system language from the OS or the browser), pref.language for scripts. A key check test guards the catalogs. The Spanish catalog is a machine draft awaiting native review; docs/translating.md explains adding a language.
2026-10-11 16:21:16 +07:00
BFlat 76df2d4136
M9: hosting figcraft-server: wss in the desktop app, server releases, deployment guide (#125)
The desktop app joins wss:// sessions (rustls with webpki roots, certificates always checked, clear errors; FIGCRAFT_CA_FILE for private authorities), with one TLS session shared by the reader and writer threads. figcraft-server stops gracefully on SIGTERM/SIGINT/SIGHUP/Ctrl+C, ships in Linux x86_64/aarch64 release archives, and docs/server.md gains a Deploy on a Linux server guide with a hardened systemd unit, Caddyfile, nginx config and Dockerfile in packaging/server.
2026-10-11 15:52:27 +07:00
BFlat a003e3de4f
M7: plugin panels and settings (#124)
Plugin ABI v2: a plugin can show a panel (text, number, checkbox, select, colour, list, notes, buttons) rendered with FigCraft's widgets, react to events with its state kept between calls (each editing callback one undo step, full sandbox limits on every call), and store per-plugin settings (64 KB). plugin.ui / plugin.event / plugin.close drive panels from the CLI and MCP; v1 plugins run unchanged. The Swatch grid example gains a Palettes panel that remembers the last palette.
2026-10-11 14:45:19 +07:00
BFlat 09a3b74466
M8: canvas fixes: boolean hit testing, group drags, frame outlines and slices view, a flaky idle test (#123)
Clicks on a boolean group test its computed result, so a click in a subtracted hole reaches what is behind it. Layers inside a group, boolean group or instance stay in their parent when dragged (one shared rule). View > Frame outlines and Slices now draw. The idle test's flake came from the render worker clearing its in-flight flag before waking the UI; jobs now carry sequence numbers.
2026-10-11 14:25:25 +07:00
BFlat 13ab3f150f
M6: code connect: link components to real code (#122)
Components (or sets) can be linked per language to a real code component (import path, name, and a template whose {prop} and {prop ? "a" : "b"} placeholders take the instance's property values, escaped per language). Dev Mode's Inspect panel shows the connected snippet with its import for instances, a Connect to code editor with live preview, and read-only plugins. Links export to and import from figcraft.connect.json.
2026-10-11 14:02:37 +07:00
BFlat cb5435d5ff
M4: stretch and scatter brushes, pen pressure (#120)
Strokes can use a brush: eight presets drawn in code (four stretched along the path, four scattered with spacing, size, rotation and offset jitter from a fixed seed), drawn by the shared stroke-outline code in canvas, PNG, SVG, PDF and outline stroke, with caps on copies and steps. Pointer events carry pen pressure; the Pencil records it as the stroke's width profile, which brushes follow (Windows pens and browser styluses; constant width elsewhere).
2026-10-11 13:43:48 +07:00
BFlat f62cb4b1f6
M4: text on a path and pattern fills (#118)
Text layers can follow a path (start offset, flip, alignment; edited in place; canvas, PNG, SVG, PDF; SVG textPath imports as real text on a path). A new pattern paint tiles a source layer in grid or hex rows with scale, spacing and alignment, live as the source changes (SVG pattern, PDF vector tiling pattern).
2026-10-11 13:25:33 +07:00
BFlat 09600d6da2
M9: team libraries on the server; swap library (#117)
A file's library can be published to figcraft-server and enabled in other files by a share link (view access is enough); placing, styles, variables and library updates with overrides kept work as for local libraries, with a background check while the Assets panel is open. library.swap re-points instances, styles and variables to another library by name (one undo step, unmatched items listed). The server keeps per-file usage reports (library.usage).
2026-10-11 13:05:46 +07:00
561 changed files with 93840 additions and 6510 deletions

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
# Scripts and server files run on Linux: keep their line endings LF in every checkout and archive
# (a copy of a Windows checkout must still run packaging/server/install.sh).
*.sh text eol=lf
packaging/server/* text eol=lf

View File

@ -35,6 +35,8 @@ jobs:
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Server install script (Caddyfile handling)
run: bash packaging/server/install-test.sh
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell

View File

@ -251,6 +251,49 @@ jobs:
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]
@ -460,7 +503,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
runs-on: ubuntu-latest
environment: release
permissions:

View File

@ -46,7 +46,8 @@ code.
`assets/`, `docs/brand/` or `docs/images/`.
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
are original. Keep it that way: new icons are new code, designed from scratch.
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`).
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`

View File

@ -11,9 +11,10 @@ MIT OR Apache-2.0. No third-party icon sets are used.
| Path | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `assets/app-icon/` (all files: `figcraft-*.png`, `figcraft.ico`, `hicolor/*/apps/ai.storyteller.figcraft.png`) | FigCraft contributors | Generated by `cargo xtask icon` (`xtask/src/icon.rs`) with FigCraft's own renderer | MIT OR Apache-2.0 | Original geometric mark (stacked layer cards and a cursor) |
| `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) |
| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only |
| `docs/images/` (all screenshots) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft | MIT OR Apache-2.0 | No third-party artwork |
| `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork |
| `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork |
## Fonts (build input, not in this repository)

View File

@ -46,7 +46,8 @@ code.
`assets/`, `docs/brand/` or `docs/images/`.
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
are original. Keep it that way: new icons are new code, designed from scratch.
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`).
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`

193
Cargo.lock generated
View File

@ -914,6 +914,17 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys 0.61.2",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@ -1396,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"arboard",
"eframe",
@ -1404,12 +1415,14 @@ dependencies = [
"env_logger",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-format",
"figcraft-text",
"figcraft-ui-egui",
"image",
"log",
"rfd",
"serde_json",
"wat",
"webbrowser",
"windows",
"winresource",
@ -1417,7 +1430,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@ -1431,7 +1444,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@ -1446,7 +1459,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@ -1456,17 +1469,20 @@ dependencies = [
"js-sys",
"log",
"proptest",
"rustls",
"rustls-pki-types",
"serde_json",
"tungstenite",
"wasm-bindgen",
"web-sys",
"web-time",
"webpki-roots",
"windows-registry",
]
[[package]]
name = "figcraft-color"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"kurbo",
"serde",
@ -1475,7 +1491,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-color",
"figcraft-geom",
@ -1487,7 +1503,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@ -1504,6 +1520,7 @@ dependencies = [
"figcraft-text",
"image",
"log",
"regex",
"serde",
"serde_json",
"thiserror 2.0.21",
@ -1513,10 +1530,13 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
"flate2",
"image",
"ruzstd",
"serde",
"serde_json",
"sha2",
@ -1525,7 +1545,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"kurbo",
"serde",
@ -1533,7 +1553,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@ -1541,7 +1561,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@ -1557,7 +1577,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@ -1566,7 +1586,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@ -1583,10 +1603,12 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-color",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
"wasmi",
"wat",
@ -1594,7 +1616,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"crc32fast",
"figcraft-color",
@ -1604,31 +1626,39 @@ dependencies = [
"figcraft-text",
"image",
"log",
"moxcms",
"png",
"rayon",
"tiny-skia",
]
[[package]]
name = "figcraft-server"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"crc32fast",
"ctrlc",
"env_logger",
"figcraft-collab",
"figcraft-collab-net",
"figcraft-doc",
"figcraft-engine",
"figcraft-format",
"figcraft-plugin",
"log",
"rustix 1.1.5",
"serde",
"serde_json",
"sha2",
"toml",
"tungstenite",
"wat",
"zip",
]
[[package]]
name = "figcraft-svg"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@ -1637,12 +1667,14 @@ dependencies = [
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"flate2",
"thiserror 2.0.21",
"usvg",
]
[[package]]
name = "figcraft-text"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@ -1650,11 +1682,12 @@ dependencies = [
"log",
"skrifa 0.47.0",
"unicode-bidi",
"unicode-segmentation",
]
[[package]]
name = "figcraft-ui-egui"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"egui",
@ -1664,6 +1697,7 @@ dependencies = [
"figcraft-engine",
"figcraft-geom",
"figcraft-layout",
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"image",
@ -1671,11 +1705,12 @@ dependencies = [
"serde",
"serde_json",
"toml",
"wat",
]
[[package]]
name = "figcraft-web"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"eframe",
"egui",
@ -1883,6 +1918,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@ -2740,6 +2786,18 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@ -3718,6 +3776,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@ -3780,6 +3852,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@ -3816,6 +3923,12 @@ dependencies = [
"unicode-script",
]
[[package]]
name = "ruzstd"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
[[package]]
name = "ryu"
version = "1.0.23"
@ -4186,6 +4299,12 @@ dependencies = [
"write-fonts",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
@ -4556,6 +4675,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@ -4671,6 +4796,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@ -5000,6 +5131,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@ -5666,14 +5806,9 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-engine",
"figcraft-render",
"figcraft-svg",
"image",
"serde_json",
]
@ -5847,6 +5982,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"

View File

@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.3.1"
version = "0.5.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@ -37,6 +37,7 @@ kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
proptest = "1"
imbl = { version = "7", features = ["serde"] }
@ -45,13 +46,20 @@ postcard = { version = "1", default-features = false, features = ["alloc"] }
# rendering / formats
tiny-skia = "0.12"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
# Already built for `image`: embedded ICC profiles (moxcms) and PNG colour chunks (png).
moxcms = "0.8"
png = "0.18"
usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"

5
NOTICE
View File

@ -17,5 +17,6 @@ Release builds made with the optional craft-fonts build input (CRAFT_FONTS_DIR)
listed in ATTRIBUTION.md (Inter and others, SIL Open Font License 1.1); each package carries their
licence texts.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, Zlib, OFL for egui's default
fonts and similar permissive licenses); see each crate's metadata.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
similar permissive licenses); see each crate's metadata.

129
README.md
View File

@ -34,11 +34,20 @@
<br>
<p align="center">
<img src="docs/images/shot-1-hero.png" alt="FigCraft in its dark theme editing the Ridgeline hiking app: three phone screens (Home in light mode, a trail detail page and Home in dark mode) on the canvas, the Featured trail card instance selected, the layers panel showing the screen's auto layout tree, and the Design panel showing the instance's Fill and Hug auto layout sizing, its Theme variable mode and a fill bound to the surface colour variable" width="100%">
<br><sub><b>Ridgeline</b>: auto layout screens built from components, with Light and Dark modes from one set of colour variables · <code>examples/ridgeline-app.figcraft</code></sub>
</p>
<br>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Designers, illustrators, developers,
> musicians, game makers &mdash; if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#a-look-around">A look around</a> ·
<a href="#made-in-figcraft">Made in FigCraft</a> ·
<a href="#features">Features</a> ·
<a href="#built-for-agents">Agents</a> ·
<a href="#under-the-hood">Under the hood</a> ·
@ -61,7 +70,7 @@
</td>
<td width="25%" valign="top">
<h3>🔌 Offline and private</h3>
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; designs exported as the public REST API file JSON import too, with their prototypes and variables.
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no telemetry, and nothing leaves your computer unless you share a file (<a href="#privacy-and-online-sharing">what sharing sends where</a>). SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
</td>
<td width="25%" valign="top">
<h3>🤖 Agent-ready</h3>
@ -72,6 +81,86 @@
<br>
## A look around
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-2-components.png" alt="The Ridgeline UI design system open with the Assets panel on the left showing live thumbnails of the Button, Difficulty, Search field, Toggle and icon components; a Button component set of nine variants laid out in a three-by-three grid auto layout is selected, and the Design panel lists its Style (Primary, Tonal, Outline) and State (Default, Hover, Disabled) variant properties" width="100%">
<p align="center"><sub><b>Components and variants</b>: component sets, variant properties and the Assets panel · <code>examples/ridgeline-design-system.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-3-prototype.png" alt="Four onboarding phone screens in the Prototype tab, joined by blue connection curves from the Get started, Continue, Allow location and Not now buttons to the next screens; the Get started button is selected and the Prototype panel shows its On click, Navigate to 2 Interests interaction" width="100%">
<p align="center"><sub><b>Prototyping</b>: connections, transitions, back actions and a flow · <code>examples/ridgeline-onboarding.figcraft</code></sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-4-devmode.png" alt="Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows generated React code with a TrailRow props interface and the instance's title and details values" width="100%">
<p align="center"><sub><b>Dev Mode</b>: ready-for-dev status, annotations and React code where component properties become props · <code>examples/ridgeline-app.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-5-vector.png" alt="Vector edit mode on a wave path with a gradient, tapered variable-width stroke: its anchor points, Bezier handles and the transform box of the selected points are visible, with the vector edit toolbar at the bottom; above it are a boolean-built mountain mark and an angular-gradient knot of excluded rings" width="100%">
<p align="center"><sub><b>Vector networks</b>: points and handles, variable-width strokes, booleans that stay editable · <code>examples/glass-and-vectors.figcraft</code></sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-6-dashboard-light.png" alt="The light UI theme with a web analytics dashboard for a fictional trail operator: a dark sidebar, KPI cards with sparklines, a grouped bar chart, a donut chart, a table of top trails and an area chart, with the frame's 12-column layout grid showing as tinted columns and its fill linked to a paint style" width="100%">
<p align="center"><sub><b>Layout grids and styles, light theme</b>: a 12-column grid, paint, text, effect and grid styles, charts drawn from shapes · <code>examples/ridgeline-studio-dashboard.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-7-collab.png" alt="A live session seen from one participant's window: avatars of three people in the top right, a Priya Nair is spotlighting banner with a Follow button, Priya's green cursor and selection outline on the dark home screen's trail card, and Jonas Ortiz's purple cursor and selection on the Start hike button with his cursor chat bubble asking whether the button should stay pinned while scrolling" width="100%">
<p align="center"><sub><b>Live sessions</b>: three apps on one <code>figcraft-server</code> file, with cursors, selections, cursor chat and spotlight</sub></p>
</td>
</tr>
</table>
## Made in FigCraft
Every design below was built entirely through FigCraft's command API, the same one the MCP server
exposes to agents, and exported at 2x by FigCraft's own renderer. The source files are in
[`examples/`](examples).
<table>
<tr>
<td width="33%" valign="top">
<img src="docs/images/art-app-home.png" alt="Ridgeline home screen in light mode: a greeting, a search field, filter chips, a featured trail card with an illustrated mountain landscape, a list of nearby trails and a translucent tab bar" width="100%">
<p align="center"><sub><b>Home, light</b></sub></p>
</td>
<td width="33%" valign="top">
<img src="docs/images/art-app-trail.png" alt="Ridgeline trail detail screen: a dusk mountain illustration with glass buttons, a sheet with difficulty tags, the trail name, distance, elevation and duration stats, an elevation profile chart and a Start hike button" width="100%">
<p align="center"><sub><b>Trail detail</b></sub></p>
</td>
<td width="33%" valign="top">
<img src="docs/images/art-app-home-dark.png" alt="The same Ridgeline home screen in dark mode, its colours switched by the Theme variable collection's Dark mode" width="100%">
<p align="center"><sub><b>Home, dark</b>: the same frame in the Dark mode</sub></p>
</td>
</tr>
<tr>
<td colspan="3" valign="top">
<img src="docs/images/art-dashboard.png" alt="Ridgeline Studio dashboard: a dark navy sidebar with navigation, KPI cards for active hikers, check-ins, average rating and open incidents with sparklines, a hikes-per-day grouped bar chart with a tooltip, a difficulty donut chart, a top trails table with condition badges and a visitors area chart" width="100%">
<p align="center"><sub><b>Ridgeline Studio</b>: a desktop dashboard on a 12-column grid, every chart made of shapes and vectors</sub></p>
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/images/art-glass-weather.png" alt="A glass weather widget over blurred teal, violet, rose and orange colour fields: a frosted card for Lake Louise showing 14 degrees and partly cloudy with a gradient sun behind a boolean-union cloud, an hourly forecast row, and smaller glass cards for air quality, wind and humidity" width="100%">
<p align="center"><sub><b>Glass weather</b>: glass and background blur, layer-blurred colour fields, a boolean cloud</sub></p>
</td>
<td width="33%" valign="top">
<img src="docs/images/art-vector-marks.png" alt="Three tiles on a cream background: a gradient mountain mark made by unioning two peaks and subtracting a sun, a knot of three excluded rings with an angular gradient, and a tapered gradient wave with a leaf and a glowing four-point star" width="100%">
<p align="center"><sub><b>Vector marks</b>: union, subtract and exclude, an angular gradient, a variable-width stroke</sub></p>
</td>
</tr>
<tr>
<td colspan="3" valign="top">
<img src="docs/images/art-ds-colour.png" alt="Ridgeline UI colour ramps: six swatches each of Pine greens, Sunset oranges and Slate greys, each labelled with its name and hex value" width="100%">
<p align="center"><sub><b>Ridgeline UI</b>: colour ramps where every swatch is a paint style, exported through a slice</sub></p>
</td>
</tr>
</table>
## Features
<table>
@ -102,7 +191,7 @@
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
@ -138,9 +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)).
- **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. 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.
@ -161,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/).
@ -168,7 +262,32 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 47% 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 can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing 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

View File

@ -1,8 +1,8 @@
# FigCraft Roadmap
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@ -13,21 +13,21 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (204 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~200–365 h** one agent | estimated |
| Remaining to full parity | **~378–722 h** one agent (~95–165 h wall-clock with 5 agents) | estimated |
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~47% | ~365–690 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
| Mainstream practitioner | ~43% | ~170–315 h | core editor depth incl. `.fig` import (88–171), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@ -45,53 +45,53 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
FigCraft can't open Figma's own `.fig` files, has only a preview of multiplayer, and nothing has been checked
against real Figma documents. Ready-for-real-work is ~47%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is missing, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
~75% **and** reliable `.fig` import: the `.fig` reader (30–60 h), a real-file corpus with measured
FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked
against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h) and
the localization layer with Figma's own languages (20–35 h).
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
native speakers' reviews of the six language drafts need people.
## By dimension
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 57% (core editor 67%) | 68–129 | [target-app-parity](docs/target-app-parity.md) |
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 10% (English only) | 60–100 | [localization-parity](docs/localization-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 20% (own WASM plugins) | 25–55 | [gaps](docs/gaps.md) |
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
| Area | Breadth | Ready | Hours | Doc |
|---|---|---|---|---|
| Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) |
| Selection & transform | 95% | 68% | 6–10 | [ui-parity](docs/ui-parity.md) |
| Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) |
| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) |
| Strokes | 90% | 72% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
| Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) |
| Text | 85% | 64% | 8–14 | [target-app-parity](docs/target-app-parity.md) |
| Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) |
| Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) |
| Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 80% | 62% | 4–8 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 35% | 20% | 25–55 | [plugins](docs/plugins.md) |
| Team libraries | 40% | 25% | 10–20 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 45% | 30% | 10–18 | [gaps](docs/gaps.md) |
| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) |
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) |
| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) |
| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) |
| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) |
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
## Languages
@ -99,34 +99,178 @@ the localization layer with Figma's own languages (20–35 h).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | en | 100% | full |
| Simplified Chinese | zh-Hans | 0% | none |
| Spanish | es | 0% | none |
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| French | fr | 0% | none |
| Portuguese | pt | 0% | none |
| French | fr | 100% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
| Indonesian | id | 0% | none |
| Japanese | ja | 0% | none |
| German | de | 0% | none |
| Korean | ko | 0% | none |
| Japanese | ja | 100% | draft (needs native review) |
| German | de | 100% | draft (needs native review) |
| Korean | ko | 100% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has no localization layer yet. Document text in any script (bidi, Arabic
shaping, IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
## Upcoming
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import, best effort, with a synthetic `.fig` corpus: 30–60 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. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h.
5. Multiplayer (M9): accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view landed): 45–90 h.
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged.
- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged.
- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged.
- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged.
- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged.
- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from
their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as
derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings,
the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the
import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width;
number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged.
text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged.
- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page,
dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a
Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting
started card that can be closed and comes back from the Help menu
- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app
quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online**
lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from
the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a
crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
upgrade only to tell which files are still there. The six draft catalogs translate every string again
- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge
preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and
object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are
kept, and dark pages (white text on them) for files made in the dark theme
- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or
**Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that
carries the host's address); others type it under Join with a code (the popover, the home screen), where a link
works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`,
`collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links)
- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and
compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing
goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before
in the design-system example). One font family; real files still unchecked (gap 3). No number moves
- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at
once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover,
people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`),
and closing a file ends its session only (before, one session followed every tab and starting another replaced it)
- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key
(Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are
kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address,
idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and
`packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups
([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature).
- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other
software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The
export now says that the document and its pages are visible and opaque, numbers its schema as the format does,
and writes outlines, text settings, standard font style names and line height units; effects such as glass and
noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers
unchanged.
- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig
(experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers,
geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and
instances with their overrides, and prototype interactions; what's left out (style links, variables, comments,
Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File
formats 60%.
- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129
shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it
is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on
Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing,
keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control
suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on
Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main
component keys; shifted symbols on other keyboard layouts.
- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key
(`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a
confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are
checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live
per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with
`figcraft-server plugins`. Plugins & widgets 60%.
- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget
plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin,
validated and built with locked layers inside; clicking a part it named calls it back with the widget's state
(kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws;
`widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and
plugin colour fields open the colour picker. Plugins & widgets 55%.
- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land
together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers
at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start
value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names,
and hidden-from-publishing names that decide both ways.
- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit
mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several
Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per
point, and the Pen connecting to its own points or continuing a selected open path.
- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel
grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide,
Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames),
outline mode (clicks reach layers behind) and measuring, each rule from the documentation.
- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows
only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under
them, layer names with slashes export into folders; export settings gained contents only, include bounding box and
the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in
rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%.
- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their
corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes
with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped;
set-variable and conditional prototype actions come across. File formats 58%, ready ~49%.
- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes,
gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal
groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent
layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through.
Paint 70%, effects 75%.
- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and
MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name
onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and
instances (expanded with their overrides and property values), variables, prototyping and images from the archive;
the rest is a warning. File formats 55%, ready ~49%.
- **2026-10-11** · Gamut warnings (gap 18): the colour picker of a Display P3 file flags colours
outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas. Showing P3 itself waits on
eframe/egui-wgpu exposing the surface colour space wgpu 30 already supports.
- **2026-10-11** · Several windows (gap 18): File ▸ New window, Open in new window on a tab or a
tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it
needs to and asks about unsaved changes when it closes (the main window too); the control channel
and MCP open, list, focus and capture windows. Hardware 60%.
- **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls,
certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux
x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on
a rented Linux server (systemd unit, Caddy or nginx, firewall, Docker, upgrades, backups).
- **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of
fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step
per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP,
and per-plugin settings are kept with the preferences; Swatch grid gained a palette panel. Plugins & widgets 45%.
- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a
subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame
outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13).
- **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps
the file's library on the server under the file (per-asset versions; the server's version is followed); other files
enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check
and accept with overrides kept; the server counts which files use which components (Show usage, `library.usage`);
`library.swap` re-points instances, styles and variables to another library by names. Team libraries 70%.
- **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the
presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12).
- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the
@ -166,6 +310,71 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. |
| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. |
| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. |
| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged |
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged |
| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged |
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged |
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding |
| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding |
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h |
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h |
| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h |
| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h |
| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h |
| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h |
| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h |
| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h |
| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding |
| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) |
| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h |
| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h |
| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) |
| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) |
| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h |
| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “<branch>”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h |
| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h |
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) |
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h |
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) |
| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding |
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged |
| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h |
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h |
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |

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" });
}

View File

@ -3,7 +3,7 @@
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind, PrintSettings};
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use serde_json::{Value, json};
@ -102,22 +102,37 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
}
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.map_err(|e| CliError::Failed(e.to_string()))?
.into_bytes(),
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))),
"fig" => fig_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
};
write(out, &bytes)
}
/// `doc` as a `.fig` file (experimental), printing what was left out.
fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
let (bytes, warnings) = figcraft_engine::cmd::file::fig_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
for w in warnings {
eprintln!("note: {w}");
}
Ok(bytes)
}
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
figcraft_engine::cmd::file::frames_pdf(doc, page, print).map(|(bytes, _)| bytes)
} else {
figcraft_engine::cmd::file::export_bytes_with(doc, &children(doc, page), ExportFormat::Pdf, 1.0, print)
figcraft_engine::cmd::file::export_bytes_with(
doc,
&children(doc, page),
&ExportSetting { format: ExportFormat::Pdf, print: *print, ..Default::default() },
1.0,
)
};
r.map_err(|e| CliError::Failed(e.to_string()))
}
@ -193,16 +208,26 @@ pub struct Target {
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
let doc = load(file)?;
// `.fig`: the whole file.
if t.format.as_deref().map_or(ext(out) == "fig", |f| f.eq_ignore_ascii_case("fig")) {
write(out, &fig_bytes(&doc)?)?;
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
let page = page_named(&doc, t.page.as_deref())?;
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf)")))?,
Some(f) => {
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
}
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of =
|ids: &[NodeId]| figcraft_engine::cmd::file::export_bytes_with(&doc, ids, fmt, scale, print).map_err(|e| CliError::Failed(e.to_string()));
let bytes_of = |ids: &[NodeId]| {
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
};
if single {
let bytes = match target {
Some(t) => bytes_of(&[t])?,
@ -354,4 +379,31 @@ mod tests {
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
}
#[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();
}
}

View File

@ -1,5 +1,6 @@
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
//! command scripts, list commands, serve MCP on stdio and measure performance.
//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin
//! directory and measure performance.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod args;
@ -16,21 +17,31 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT]
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
Files: .figcraft (native), .json (FigCraft JSON), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image
layer). `export` writes one file per top-level frame into the <out> directory, or one file when <out>
has an image extension or --node is given; PDFs take print options (a bleed like 3mm or 0.125in,
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
See docs/cli.md and docs/mcp.md.";
fn main() -> ExitCode {
@ -65,6 +76,13 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
if system_fonts {
// Text lays out with the installed fonts, like the desktop app, so headless renders and
// auto layout sizes match what users see.
figcraft_text::scan_system_fonts_on_demand();
}
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
match cmd.as_str() {
"help" | "--help" | "-h" => {
@ -121,11 +139,21 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
mcp(a.value("attach").as_deref())
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
match a.positional.as_slice() {
[verb, folder] if verb == "publish" => {
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
publish_plugin(folder, &server)
}
_ => Err(CliError::Usage("expected: plugin publish <folder> --server ws://host:port/".into())),
}
}
"perf" => {
let a = Args::parse(rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances)
perf::run(&sizes, &instances, system_fonts)
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
@ -154,6 +182,29 @@ fn commands(as_json: bool, filter: Option<&str>) {
}
}
/// Publish the plugin folder `folder` to the plugin directory at `server` (the key from the address
/// or `FIGCRAFT_SERVER_KEY`); never prints the key.
fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
let address = server.split('?').next().unwrap_or_default().to_string();
let server = match std::env::var("FIGCRAFT_SERVER_KEY") {
Ok(key) if !server.contains("key=") && !key.trim().is_empty() => format!("{address}?key={}", key.trim()),
_ => server.to_string(),
};
let mut h = figcraft_mcp::Headless::new();
use figcraft_mcp::Backend as _;
let r = h.call("engine.execute", json!({"command": "plugin.directory.publish", "params": {"path": folder, "server": server}}))?;
let e = &r["published"];
println!(
"Published {} {} ({}) to {address}: {} bytes, SHA-256 {}",
e["name"].as_str().unwrap_or_default(),
e["version"].as_str().unwrap_or_default(),
e["id"].as_str().unwrap_or_default(),
e["bytes"],
e["sha256"].as_str().unwrap_or_default()
);
Ok(ExitCode::SUCCESS)
}
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {

View File

@ -157,7 +157,10 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
let (fx, fy) = ((i * 7919 % 1000) as f64 / 1000.0, (i * 104_729 % 1000) as f64 / 1000.0);
Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy)
};
table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0)) * 1000.0);
{
let result = figcraft_engine::booleans::result_outline(&doc, &[]);
table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0, Some(&result))) * 1000.0);
}
measure_boolean_drag(table, col);
let mut s = Session::with_document(doc);
@ -175,8 +178,19 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
"hover pointer move (µs each)",
mean(hits, |i| {
let p = pt(i);
let ev =
PointerEvent { kind: PointerKind::Hover, x: p.x, y: p.y, sx: 0.0, sy: 0.0, shift: false, alt: false, cmd: false, ctrl: false, zoom };
let ev = PointerEvent {
kind: PointerKind::Hover,
x: p.x,
y: p.y,
sx: 0.0,
sy: 0.0,
shift: false,
alt: false,
cmd: false,
ctrl: false,
zoom,
pressure: None,
};
let _ = s.pointer(&ev);
}) * 1000.0,
);
@ -278,9 +292,11 @@ fn measure_boolean_drag(table: &mut Table, col: usize) {
/// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design
/// system, for each instance count in `instances`; print the tables.
pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult<ExitCode> {
// Text renders with system fonts too, like the desktop app.
figcraft_text::scan_system_fonts();
pub fn run(sizes: &[usize], instances: &[usize], system_fonts: bool) -> CliResult<ExitCode> {
// Text renders with system fonts too, like the desktop app; scanned up front, outside the timings.
if system_fonts {
figcraft_text::scan_system_fonts();
}
let mut table = Table { rows: Vec::new() };
for (col, n) in sizes.iter().enumerate() {
eprintln!("measuring {n} layers…");

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();
}

View File

@ -0,0 +1,47 @@
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
use serde_json::{Value, json};
/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment
/// variable.
fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join(format!("{name}.json"));
std::fs::write(&script, json!([step]).to_string()).unwrap();
let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
cmd.arg("run").arg(&script).args(args);
if opt_out {
cmd.env("FIGCRAFT_NO_SYSTEM_FONTS", "1");
} else {
cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS");
}
let out = cmd.output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let line: Value = serde_json::from_slice(&out.stdout).unwrap();
line["result"].clone()
}
fn families(args: &[&str], opt_out: bool) -> Vec<String> {
serde_json::from_value(run_one("families", json!({"command": "text.fonts"}), args, opt_out)).unwrap()
}
#[test]
fn headless_runs_resolve_installed_families_unless_opted_out() {
let built_in = families(&["--no-system-fonts"], false);
assert_eq!(families(&[], true), built_in, "FIGCRAFT_NO_SYSTEM_FONTS=1 opts out like the flag");
// Any family this machine adds (scanned here, in the test); none (CI): nothing to check.
figcraft_text::scan_system_fonts();
let Some(installed) = figcraft_text::family_names().into_iter().find(|f| !built_in.contains(f)) else {
eprintln!("skipped: no installed fonts beyond the built-in ones");
return;
};
assert!(families(&[], false).contains(&installed), "headless runs list {installed}");
let face = json!({"command": "text.fonts", "params": {"family": installed, "axes": true}});
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
}

View File

@ -19,6 +19,10 @@ workspace = true
[dependencies]
figcraft-collab = { workspace = true }
figcraft-collab-net = { workspace = true }
# Checking a team library before keeping it (read as files using it read it).
figcraft-format = { workspace = true }
# Checking plugin bundles before the plugin directory keeps them (never run here).
figcraft-plugin = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@ -26,8 +30,17 @@ sha2 = { workspace = true }
crc32fast = { workspace = true }
toml = "1"
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
# Stopping gracefully on SIGTERM/SIGINT/SIGHUP (systemd, Docker, Ctrl+C) and Ctrl+C/close on Windows.
ctrlc = { version = "3", features = ["termination"] }
# Free space on the data directory's disk (statvfs, without unsafe code of our own).
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "fs"] }
[dev-dependencies]
# Crafting hostile archives (zip bombs) in tests.
zip = { workspace = true }
wat = { workspace = true }
figcraft-engine = { workspace = true }
figcraft-doc = { workspace = true }
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }

View File

@ -0,0 +1,227 @@
//! What one address may do how often: new connections, publishing without the key and messages
//! (only on a server open to the public), and reports and downloads (always). Each is a token
//! bucket per block an address counts in ([`ip_blocks`]: an IPv4 address, or an IPv6 /64 and its
//! /56 and /48 with 2 and 4 times the rate), so a tunnel's /48 doesn't get 65,536 /64s' worth. Publishing has a budget for
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
//! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood
//! of addresses costs bounded memory (and forgotten addresses start with full buckets).
//!
//! Join codes are short, so guessing them is held back three ways: an address may try
//! [`CODES_PER_MINUTE`] codes a minute, [`WRONG_CODES`] wrong ones in a row lock it out of codes for
//! [`CODE_LOCKOUT`] (links still join), and everyone together may get [`WRONG_CODES_PER_HOUR`]
//! wrong an hour (an attacker with many addresses is slowed to that; while it lasts, people join
//! with their links).
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use figcraft_collab_net::hub::{Block, ip_blocks};
use figcraft_collab_net::net::Bucket;
/// Most addresses remembered.
pub const MAX_ADDRESSES: usize = 10_000;
/// Reports one address may send an hour.
pub const REPORTS_PER_HOUR: f64 = 10.0;
/// Bytes one address may download a second (team libraries, plugins), and at once: an IPv6 /48
/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once).
pub const DOWNLOAD_RATE: f64 = (1u64 << 20) as f64;
pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
/// Everyone together may publish without the key this many times what one address may: eight
/// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all.
pub const PUBLISHES_ALL: f64 = 32.0;
/// Join codes one address may try a minute (right or wrong).
pub const CODES_PER_MINUTE: f64 = 10.0;
/// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes.
pub const WRONG_CODES: u32 = 10;
/// How long an address is locked out of join codes.
pub const CODE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
/// Wrong codes everyone together may try an hour.
pub const WRONG_CODES_PER_HOUR: f64 = 1_000.0;
/// The rates (see the server's settings).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AbuseConfig {
/// Open to the public: connections, publishing and messages are limited.
pub open: bool,
pub connects_per_minute: u32,
pub publishes_per_hour: u32,
pub messages_per_second: u32,
}
/// What an address does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Connect,
Publish,
Message,
/// Presence (cursors, selections, viewports), cursor chat and pings: twice `messages_per_second`.
Presence,
Report,
/// Downloading this many bytes.
Download(usize),
/// Trying a join code.
Code,
}
struct Address {
connects: Bucket,
publishes: Bucket,
messages: Bucket,
presence: Bucket,
reports: Bucket,
downloads: Bucket,
codes: Bucket,
/// Wrong join codes in a row, and when the last one was.
wrong: u32,
wrong_at: Instant,
/// Locked out of join codes until then.
locked: Option<Instant>,
used: Instant,
}
/// The buckets of the addresses seen lately.
pub struct Abuse {
cfg: AbuseConfig,
table: HashMap<Block, Address>,
/// Publishing without the key, everyone together.
publishes: Bucket,
/// Wrong join codes, everyone together.
wrong_codes: Bucket,
}
/// `n` every `period` seconds on average, and all `n` at once at most.
fn bucket(n: f64, period: f64) -> Bucket {
Bucket::new(n / period, n.max(1.0))
}
impl Abuse {
pub fn new(cfg: AbuseConfig) -> Self {
let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0);
Self { cfg, table: HashMap::new(), publishes, wrong_codes: bucket(WRONG_CODES_PER_HOUR, 3600.0) }
}
/// How long `ip` is still locked out of join codes (in any block it is in), if it is.
pub fn code_locked(&self, ip: IpAddr, now: Instant) -> Option<Duration> {
let until = ip_blocks(ip).into_iter().filter_map(|(k, _)| self.table.get(&k)?.locked).max()?;
Some(until.saturating_duration_since(now)).filter(|d| !d.is_zero())
}
/// Everyone together has wrong codes left to try (see [`WRONG_CODES_PER_HOUR`]).
pub fn codes_open(&mut self, now: Instant) -> bool {
let open = self.wrong_codes.take(now);
if open {
self.wrong_codes.give(1.0);
}
open
}
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
/// before it; enough wrong ones in a row lock the address out.
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
if !ok {
self.wrong_codes.take(now);
}
for (k, times) in ip_blocks(ip) {
let a = self.address(k, f64::from(times), now);
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
a.wrong += 1;
a.wrong_at = now;
if a.wrong >= WRONG_CODES.saturating_mul(times) {
a.wrong = 0;
a.locked = Some(now + CODE_LOCKOUT);
}
}
}
}
/// Whether `ip` may do `what` now (counting it, in every block it is in, when it may).
pub fn allow(&mut self, ip: IpAddr, what: Action, now: Instant) -> bool {
if !self.cfg.open && matches!(what, Action::Connect | Action::Publish | Action::Message | Action::Presence) {
return true;
}
let keys: Vec<(Block, f64)> = ip_blocks(ip).into_iter().map(|(k, times)| (k, f64::from(times))).collect();
// Every block must have room before any is charged.
if !keys.iter().all(|&(k, times)| self.room(k, times, what, now)) {
return false;
}
if what == Action::Publish && !self.publishes.take(now) {
return false;
}
for &(k, times) in &keys {
let (b, n) = Self::pick(self.address(k, times, now), what);
b.take_n(now, n);
}
true
}
/// The address's buckets, made when it is new (forgetting the oldest half when full).
fn address(&mut self, ip: Block, times: f64, now: Instant) -> &mut Address {
if !self.table.contains_key(&ip) && self.table.len() >= MAX_ADDRESSES {
self.forget_oldest_half();
}
let c = self.cfg;
let a = self.table.entry(ip).or_insert_with(|| Address {
connects: bucket(f64::from(c.connects_per_minute) * times, 60.0),
publishes: bucket(f64::from(c.publishes_per_hour) * times, 3600.0),
// A burst of four seconds' worth (a big paste, a quick drag).
messages: Bucket::new(f64::from(c.messages_per_second) * times, f64::from(c.messages_per_second) * times * 4.0),
presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0),
reports: bucket(REPORTS_PER_HOUR * times, 3600.0),
downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times),
codes: bucket(CODES_PER_MINUTE * times, 60.0),
wrong: 0,
wrong_at: now,
locked: None,
used: now,
});
a.used = now;
a
}
fn pick(a: &mut Address, what: Action) -> (&mut Bucket, f64) {
match what {
Action::Connect => (&mut a.connects, 1.0),
Action::Publish => (&mut a.publishes, 1.0),
Action::Message => (&mut a.messages, 1.0),
Action::Presence => (&mut a.presence, 1.0),
Action::Report => (&mut a.reports, 1.0),
Action::Download(n) => (&mut a.downloads, n as f64),
Action::Code => (&mut a.codes, 1.0),
}
}
/// The block has `what`'s tokens now (nothing is spent).
fn room(&mut self, ip: Block, times: f64, what: Action, now: Instant) -> bool {
let (b, n) = Self::pick(self.address(ip, times, now), what);
if b.take_n(now, n) {
b.give(n);
return true;
}
false
}
/// Addresses remembered.
pub fn len(&self) -> usize {
self.table.len()
}
/// None remembered.
pub fn is_empty(&self) -> bool {
self.table.is_empty()
}
fn forget_oldest_half(&mut self) {
let mut used: Vec<Instant> = self.table.values().map(|a| a.used).collect();
used.sort_unstable();
let Some(&cut) = used.get(used.len() / 2) else { return };
self.table.retain(|_, a| a.used > cut);
}
}

View File

@ -0,0 +1,95 @@
//! The app's way of talking to a server without waiting (`Session::start_background`), end to end
//! over loopback: browsing and installing from the plugin directory, publishing a team library,
//! adding it and asking for its usage each return at once and finish when the answers arrive; a
//! server that never answers doesn't hold the caller up.
use std::net::TcpListener;
use std::time::{Duration, Instant};
use figcraft_engine::Session;
use figcraft_plugin::bundle::Bundle;
use serde_json::{Value, json};
use crate::store::Store;
use crate::test_util::{TempDir, config, publish, pump, session};
const ID: &str = "org.example.tiny";
fn bundle() -> Vec<u8> {
let wasm =
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}});
Bundle { manifest: manifest.to_string(), wasm, assets: Vec::new() }.encode()
}
/// Start `cmd` without waiting (it must not finish at once: its answer comes over the network),
/// then pump the session until it finished; its result.
fn background(s: &mut Session, cmd: &str, params: Value) -> Value {
let started = Instant::now();
assert_eq!(s.start_background(cmd, params).unwrap(), None, "{cmd} waits for its answer");
assert!(started.elapsed() < Duration::from_secs(1), "{cmd} returned at once");
pump(&mut [&mut *s], cmd, |s| !s[0].waiting_for(cmd));
let (done, r) = s.take_background_done().unwrap();
assert_eq!(done, cmd);
r.unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> Value {
v["id"].clone()
}
#[test]
fn browsing_and_installing_finish_in_the_background() {
let dir = TempDir::new("background-plugins");
Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle()).unwrap();
let mut server = crate::start(&config(dir.path())).unwrap();
let plain = format!("ws://{}/", server.addr());
let mut s = session("Bo");
let listed = background(&mut s, "plugin.directory.list", json!({"server": plain}));
assert_eq!(listed["plugins"][0]["id"], ID);
assert_eq!(s.plugins.directory.as_ref().map(|l| l.entries.len()), Some(1), "Browse shows the listing");
let sha = listed["plugins"][0]["sha256"].clone();
let r = background(&mut s, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha, "server": plain}));
assert_eq!(r["installed"], ID);
assert_eq!(s.prefs.plugins.len(), 1);
assert!(s.plugins.unsaved, "the app saves the plugin list at once");
// Scripts still wait for their answer.
assert_eq!(s.execute("plugin.directory.list", &json!({})).unwrap()["plugins"][0]["installed"], "1.0.0");
server.stop();
}
#[test]
fn team_library_requests_finish_in_the_background() {
let dir = TempDir::new("background-library");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let f = a.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})).unwrap();
a.execute("component.create", &json!({"ids": [id(&f)]})).unwrap();
let p = publish(&mut a, dir.path(), addr);
// Fetching the server's current library, then publishing: two answers.
assert_eq!(background(&mut a, "library.publishToServer", json!({}))["version"], 1);
let mut b = session("Bo");
let added = background(&mut b, "library.add", json!({"path": p.view}));
assert_eq!(added["components"].as_array().map(Vec::len), Some(1));
assert_eq!(b.doc().unwrap().libraries.enabled.len(), 1);
let usage = background(&mut a, "library.usage", json!({}));
assert!(usage["files"].is_array(), "{usage}");
server.stop();
}
#[test]
fn a_server_that_never_answers_holds_nothing_up() {
// It never accepts: connections wait in its backlog, and no answer ever comes.
let hole = TcpListener::bind("127.0.0.1:0").unwrap();
let server = format!("ws://{}/", hole.local_addr().unwrap());
let mut s = session("Cy");
let started = Instant::now();
assert_eq!(s.start_background("plugin.directory.list", json!({"server": server})).unwrap(), None);
assert!(started.elapsed() < Duration::from_secs(1), "the caller didn't wait");
assert!(s.waiting_for("plugin.directory.list"));
// Closing the window stops waiting; the answer, when the request gives up, is dropped.
s.cancel_background("plugin.directory.list");
assert!(!s.waiting_for("plugin.directory.list") && s.take_background_done().is_none());
drop(hole);
}

View File

@ -1,5 +1,5 @@
//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data
//! directory. When a server is running on the directory they ask it (so revoking a link
//! The command line: `figcraft-server` serves; `key`, `files`, `links` and `plugins` manage the
//! data directory. When a server is running on the directory they ask it (so revoking a link
//! disconnects whoever joined with it at once); otherwise they change the directory themselves.
use std::fmt::Write as _;
@ -7,7 +7,7 @@ use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
use figcraft_collab::message::{Access, Admin, AdminReply, FileRow, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq, ReportInfo};
use figcraft_collab_net::hub::Backend;
use figcraft_collab_net::{Endpoint, Invite, manage};
use serde::{Deserialize, Serialize};
@ -25,34 +25,67 @@ figcraft-server: FigCraft's collaboration server (files, share links, live sessi
Usage:
figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
figcraft-server key [--rotate] the address to publish to, with the server key
figcraft-server files [list] the files kept
figcraft-server check check the settings (after editing the --config file)
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
figcraft-server files remove FILE remove a file and its links for good
figcraft-server links list FILE a file's share links
figcraft-server links create FILE --access edit|view [--label TEXT]
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
figcraft-server plugins [list] the plugin directory: every plugin and its versions
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
figcraft-server reports what people reported about files
figcraft-server block-ip IP refuse an address (an IPv6 address: its /64) and drop its connections
figcraft-server unblock-ip IP accept an address again
figcraft-server blocked the blocked addresses
Options (also keys of the --config TOML file):
--listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb
max_connections, max_people, compact_after, compact_mb, max_plugins,
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
connects_per_minute, publishes_per_hour, messages_per_second
-h, --help this text
-V, --version the version
Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
Logs go to standard output (RUST_LOG=debug for more); they never contain keys, tokens or file contents.
GET /healthz on the same port answers \"ok\" (for uptime checks).
";
/// How `files list` orders the files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sort {
/// By id.
#[default]
Id,
/// Biggest first.
Size,
/// Oldest first.
Age,
/// Longest unused first.
Idle,
}
/// What to do.
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Serve,
Key { rotate: bool },
Files,
Files { sort: Sort },
RemoveFile { file: String },
Links { file: String },
CreateLink { file: String, access: Access, label: String },
Revoke { file: String, link: String },
Plugins,
RemovePlugin { id: String, version: Option<String> },
Reports,
Check,
Block { ip: String },
Unblock { ip: String },
Blocked,
Help,
Version,
}
@ -75,11 +108,15 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
let mut access = None;
let mut label = String::new();
let mut rotate = false;
let mut version = None;
let mut sort = Sort::Id;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
match a.as_str() {
"-h" | "--help" => return Ok(Cli { config, command: Command::Help }),
// After `plugins remove ID`: the version to unpublish.
"--version" if words.first().is_some_and(|w| w == "plugins") => version = Some(value("--version")?),
"-V" | "--version" => return Ok(Cli { config, command: Command::Version }),
"--config" => {
value("--config")?;
@ -90,6 +127,14 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
"--label" => label = value("--label")?,
"--rotate" => rotate = true,
"--sort" => {
sort = match value("--sort")?.as_str() {
"size" => Sort::Size,
"age" => Sort::Age,
"idle" => Sort::Idle,
_ => return Err("--sort must be size, age or idle".into()),
}
}
s if s.starts_with('-') => return Err(format!("unknown option {s}")),
s => words.push(s.to_string()),
}
@ -98,13 +143,20 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
let command = match w.as_slice() {
[] | ["serve"] => Command::Serve,
["key"] => Command::Key { rotate },
["files"] | ["files", "list"] => Command::Files,
["files"] | ["files", "list"] => Command::Files { sort },
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
["links", "list", file] => Command::Links { file: file.to_string() },
["links", "create", file] => {
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
}
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
["plugins"] | ["plugins", "list"] => Command::Plugins,
["plugins", "remove", id] => Command::RemovePlugin { id: id.to_string(), version },
["reports"] => Command::Reports,
["check"] => Command::Check,
["block-ip", ip] => Command::Block { ip: ip.to_string() },
["unblock-ip", ip] => Command::Unblock { ip: ip.to_string() },
["blocked"] => Command::Blocked,
_ => return Err(format!("unknown command `{}`", words.join(" "))),
};
config.check()?;
@ -151,8 +203,29 @@ pub fn run(cli: &Cli) -> Result<String, String> {
Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
Command::Key { rotate } => key(cfg, *rotate),
Command::Files => match admin(cfg, Admin::Files)? {
AdminReply::Files(files) => Ok(files_table(&files)),
Command::Files { sort } => match admin(cfg, Admin::Listing)? {
AdminReply::Listing(files) => Ok(files_table(files, *sort, store::now())),
other => unexpected(other),
},
// `parse` checked them.
Command::Check => {
Ok(format!("The settings are fine{}.\n", if cfg.open_publish { ": open to the public (anyone may publish without the key)" } else { "" }))
}
Command::Reports => match admin(cfg, Admin::Reports)? {
AdminReply::Reports(reports) => Ok(reports_table(&reports)),
other => unexpected(other),
},
Command::Block { ip } => match admin(cfg, Admin::Block { ip: ip.clone() })? {
AdminReply::Done => Ok(format!("Blocked {ip}: its connections were dropped and new ones are refused.\n")),
other => unexpected(other),
},
Command::Unblock { ip } => match admin(cfg, Admin::Unblock { ip: ip.clone() })? {
AdminReply::Done => Ok(format!("Unblocked {ip}.\n")),
other => unexpected(other),
},
Command::Blocked => match admin(cfg, Admin::Blocked)? {
AdminReply::Blocked(list) if list.is_empty() => Ok("No addresses are blocked.\n".into()),
AdminReply::Blocked(list) => Ok(list.iter().map(|ip| format!("{ip}\n")).collect()),
other => unexpected(other),
},
Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
@ -173,9 +246,59 @@ pub fn run(cli: &Cli) -> Result<String, String> {
AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")),
other => unexpected(other),
},
Command::Plugins => match directory(cfg, PluginReq::List { query: String::new() })? {
PluginReply::List(entries) => Ok(plugins_table(&entries)),
other => unexpected_plugins(other),
},
Command::RemovePlugin { id, version } => match directory(cfg, PluginReq::Unpublish { id: id.clone(), version: version.clone() })? {
PluginReply::Removed(versions) => Ok(format!("Unpublished {id} {}.\n", versions.join(", "))),
other => unexpected_plugins(other),
},
}
}
fn unexpected_plugins(reply: PluginReply) -> Result<String, String> {
match reply {
PluginReply::Failed { reason } => Err(reason),
_ => Err("the server answered something else".into()),
}
}
/// Do one plugin directory request with the server key: through the running server, or on the
/// data directory.
fn directory(cfg: &Config, req: PluginReq) -> Result<PluginReply, String> {
match Store::try_open(&cfg.data, cfg.store())? {
Some(mut store) => Ok(store.directory().handle(true, req)),
None => {
let addr = running(&cfg.data)?;
let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
manage::plugins(&Endpoint::plain(addr.to_string()), key.trim(), req, &cfg.net(), Duration::from_secs(5))
}
}
}
fn plugins_table(entries: &[PluginEntry]) -> String {
if entries.is_empty() {
return "No plugins yet (publish one with `figcraft-cli plugin publish FOLDER --server ws://host:port/?key=KEY`).\n".into();
}
let mut out =
String::from("ID VERSIONS NAME AUTHOR ACCESS SIZE SHA-256\n");
for e in entries {
let _ = writeln!(
out,
"{:<32} {:<18} {:<20} {:<16} {:<6} {:<9} {}",
elide(&e.id, 32),
elide(&e.versions.join(","), 18),
elide(&e.name, 20),
elide(&e.author, 16),
e.access,
size(e.bytes),
e.sha256.get(..16).unwrap_or_default()
);
}
out
}
fn unexpected(reply: AdminReply) -> Result<String, String> {
match reply {
AdminReply::Failed { reason } => Err(reason),
@ -221,9 +344,12 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
if rotate {
out.push_str("A new server key: the old one no longer works.\n");
}
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
let _ = writeln!(out, "Publish to this address (in FigCraft: File > Publish to server…):");
let _ = writeln!(out, " {}?key={key}", public.url());
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
if cfg.open_publish {
let _ = writeln!(out, "Open to the public: anyone can publish to {} without the key (each file gets an owner token).", public.url());
}
Ok(out)
}
@ -242,17 +368,60 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
Ok(out)
}
fn files_table(files: &[FileInfo]) -> String {
/// When a listed file was last used (made, changed or opened).
fn used(f: &FileRow) -> u64 {
f.info.created.max(f.info.updated).max(f.opened)
}
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
if files.is_empty() {
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
return "No files yet (publish one from FigCraft: File > Publish to server…).\n".into();
}
let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
for f in files {
let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
match sort {
Sort::Id => {}
Sort::Size => files.sort_by_key(|f| std::cmp::Reverse(f.info.bytes)),
Sort::Age => files.sort_by_key(|f| f.info.created),
Sort::Idle => files.sort_by_key(used),
}
let mut out = String::from("ID NAME PEOPLE SIZE CREATED IDLE EXPIRES REPORTS BY\n");
for f in &files {
let idle = format!("{}d", now.saturating_sub(used(f)) / 86_400);
let expires = f.expires.map_or_else(|| "never".to_string(), day);
let by = if f.open { f.ip.as_str() } else { "key" };
let _ = writeln!(
out,
"{:<16} {:<30} {:>6} {:<9} {:<10} {:>4} {:<10} {:>7} {}",
f.info.id,
elide(&f.info.name, 30),
f.info.people,
size(f.info.bytes),
day(f.info.created),
idle,
expires,
f.reports,
by
);
}
out
}
fn reports_table(reports: &[ReportInfo]) -> String {
if reports.is_empty() {
return "No reports.\n".into();
}
let mut out = String::new();
for r in reports {
let _ = writeln!(out, "{} file {} ({}) from {} link {}", date(r.at), r.file, elide(&r.name, 40), r.ip, r.link);
let _ = writeln!(out, " {}", r.reason);
}
out
}
/// `YYYY-MM-DD` for seconds since 1970.
fn day(secs: u64) -> String {
date(secs).chars().take(10).collect()
}
fn links_table(links: &[LinkInfo]) -> String {
if links.is_empty() {
return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
@ -277,18 +446,4 @@ fn size(bytes: u64) -> String {
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
pub fn date(secs: u64) -> String {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
pub use figcraft_collab::message::utc_date as date;

View File

@ -5,7 +5,7 @@ use figcraft_collab::Replica;
use figcraft_collab::message::Access;
use figcraft_doc::Document;
use crate::cli::{self, Cli, Command, date, parse};
use crate::cli::{self, Cli, Command, Sort, date, parse};
use crate::config::Config;
use crate::store::Store;
use crate::test_util::{TempDir, config};
@ -36,7 +36,7 @@ fn parsing() {
parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command,
Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() }
);
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files);
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files { sort: Sort::Id });
assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true });
assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help);
for bad in
@ -60,7 +60,7 @@ fn parsing() {
fn managing_a_directory_on_its_own() {
let dir = TempDir::new("cli-offline");
let file = with_file(dir.path());
let list = run(dir.path(), Command::Files).unwrap();
let list = run(dir.path(), Command::Files { sort: Sort::Id }).unwrap();
assert!(list.contains(&file) && list.contains("Board"), "{list}");
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap();
assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}");
@ -75,7 +75,7 @@ fn managing_a_directory_on_its_own() {
let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap();
assert_ne!(key.lines().nth(1), rotated.lines().nth(2));
run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap();
assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet"));
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("No files yet"));
assert!(run(dir.path(), Command::Links { file }).is_err());
}
@ -91,12 +91,12 @@ fn managing_through_the_running_server() {
assert!(links.contains("Team"), "{links}");
let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked"));
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server"));
assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr())));
server.stop();
// Stopped: the directory is free again.
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
}
#[test]

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)
}

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: "abuse@example.com".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);
}

View File

@ -1,12 +1,14 @@
//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
use std::net::SocketAddr;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use figcraft_collab_net::{Endpoint, NetLimits};
use serde::Deserialize;
use crate::store::StoreConfig;
use crate::abuse::AbuseConfig;
use crate::directory::DirectoryConfig;
use crate::store::{HostingConfig, StoreConfig};
/// The port the server listens on unless told otherwise (the app's sessions try it first too).
pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
@ -25,8 +27,8 @@ pub struct Config {
/// Most files kept.
pub max_files: usize,
/// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
/// most 64 MiB).
pub max_file_mb: u64,
/// most 64 MiB). Unset: 64, or 16 when open to the public ([`Config::file_mb`]).
pub max_file_mb: Option<u64>,
/// Most share links per file.
pub max_links: usize,
/// Most open connections.
@ -37,6 +39,36 @@ pub struct Config {
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
/// Most plugin versions the plugin directory keeps (0 turns it off).
pub max_plugins: usize,
/// Largest plugin bundle, in MiB.
pub max_plugin_mb: u64,
/// Most MiB of plugin bundles kept.
pub max_plugins_mb: u64,
/// Anyone may publish a file without the server key (and gets an owner token for it): a
/// server open to the public. The limits below then apply per address.
pub open_publish: bool,
/// How to reach the operator (an email address or a web page) and the terms of use (a web
/// address): the app shows them where people publish.
pub operator_contact: String,
pub terms_url: String,
/// The reverse proxy in front of the server (`127.0.0.1` for Caddy or nginx on the same
/// machine): only its `X-Forwarded-For` is believed.
pub trusted_proxy: Option<String>,
/// Files published without the key are deleted after this many days without anyone opening
/// them (0: kept; fractions allowed).
pub idle_days: f64,
/// Publishing without the key stops while the files take this many MiB in all.
pub max_total_mb: u64,
/// Per address, when open: most files published without the key, and their MiB.
pub max_files_per_ip: usize,
pub max_mb_per_ip: u64,
/// Per address, when open: most connections at once, new connections a minute, files
/// published an hour and messages a second.
pub max_connections_per_ip: usize,
pub connects_per_minute: u32,
pub publishes_per_hour: u32,
pub messages_per_second: u32,
}
impl Default for Config {
@ -46,12 +78,27 @@ impl Default for Config {
data: PathBuf::from("figcraft-data"),
public_url: None,
max_files: 1000,
max_file_mb: 64,
max_file_mb: None,
max_links: 100,
max_connections: 512,
max_people: 256,
compact_after: 1000,
compact_mb: 16,
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
open_publish: false,
operator_contact: String::new(),
terms_url: String::new(),
trusted_proxy: None,
idle_days: 30.0,
max_total_mb: 10_240,
max_files_per_ip: 20,
max_mb_per_ip: 256,
max_connections_per_ip: 32,
connects_per_minute: 30,
publishes_per_hour: 10,
messages_per_second: 200,
}
}
}
@ -78,26 +125,93 @@ impl Config {
Ok(Some(e))
}
/// The largest file in MiB: as set, else 64, or 16 on a server open to the public (strangers'
/// files stay small unless the operator says otherwise).
pub fn file_mb(&self) -> u64 {
self.max_file_mb.unwrap_or(if self.open_publish { 16 } else { 64 })
}
/// What an operator should hear about these settings at startup (a public server without an
/// abuse contact, or one whose clients would all count as its proxy).
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
if !self.open_publish {
return out;
}
if self.operator_contact.trim().is_empty() {
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
}
let behind_proxy = self.public().ok().flatten().is_some_and(|e| e.secure) || self.addr().is_ok_and(|a| a.ip().is_loopback());
if behind_proxy && self.trusted_proxy.as_deref().is_none_or(|p| p.trim().is_empty()) {
out.push(
"open_publish is on without trusted_proxy: behind a reverse proxy every client counts as the proxy's address, so one person's limits stop everyone; set trusted_proxy = \"127.0.0.1\"".into(),
);
}
if self.idle_days == 0.0 {
out.push("open_publish is on with idle_days = 0: files published without the key are never deleted".into());
}
out
}
/// The trusted proxy's address, when set.
pub fn proxy(&self) -> Result<Option<IpAddr>, String> {
let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) };
p.parse().map(Some).map_err(|_| "`trusted_proxy` must be an IP address, like 127.0.0.1".to_string())
}
/// Check everything (before starting).
pub fn check(&self) -> Result<(), String> {
self.addr()?;
self.public()?;
self.proxy()?;
if !self.idle_days.is_finite() || !(0.0..=3650.0).contains(&self.idle_days) {
return Err("`idle_days` must be from 0 to 3650".into());
}
let terms = self.terms_url.trim();
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
return Err("`terms_url` must be a web address (https://…)".into());
}
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
return Err("`operator_contact` is at most 120 characters and `terms_url` at most 500, on one line".into());
}
for (name, v) in [
("max_files", self.max_files as u64),
("max_file_mb", self.max_file_mb),
("max_links", self.max_links as u64),
("max_connections", self.max_connections as u64),
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_total_mb", self.max_total_mb),
("max_files_per_ip", self.max_files_per_ip as u64),
("max_mb_per_ip", self.max_mb_per_ip),
("max_connections_per_ip", self.max_connections_per_ip as u64),
("connects_per_minute", u64::from(self.connects_per_minute)),
("publishes_per_hour", u64::from(self.publishes_per_hour)),
("messages_per_second", u64::from(self.messages_per_second)),
] {
if v == 0 {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
for (name, v) in [
("max_files", self.max_files as u64),
("max_file_mb", self.file_mb()),
("max_links", self.max_links as u64),
("max_connections", self.max_connections as u64),
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_plugin_mb", self.max_plugin_mb),
("max_plugins_mb", self.max_plugins_mb),
] {
if v == 0 {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.file_mb() > 4096 || self.compact_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
// A bundle travels in one message (at most 64 MiB) next to its entry.
if self.max_plugin_mb > 32 {
return Err("`max_plugin_mb` is at most 32".into());
}
if self.max_plugins > 10_000 || self.max_plugins_mb > 1 << 20 {
return Err("`max_plugins` is at most 10000 and `max_plugins_mb` at most 1048576".into());
}
Ok(())
}
@ -105,17 +219,84 @@ impl Config {
pub fn store(&self) -> StoreConfig {
let mib = |n: u64| n.saturating_mul(1 << 20);
StoreConfig {
hosting: HostingConfig {
open: self.open_publish,
operator: self.operator_contact.trim().to_string(),
terms: self.terms_url.trim().to_string(),
// Whole seconds: `check` keeps the days finite and at most 3650.
idle_secs: (self.idle_days * 86_400.0).round() as u64,
max_file_mb: self.file_mb(),
max_total_bytes: mib(self.max_total_mb),
max_files_per_ip: self.max_files_per_ip,
max_bytes_per_ip: mib(self.max_mb_per_ip),
abuse: AbuseConfig {
open: self.open_publish,
connects_per_minute: self.connects_per_minute,
publishes_per_hour: self.publishes_per_hour,
messages_per_second: self.messages_per_second,
},
},
max_files: self.max_files,
max_file_bytes: mib(self.max_file_mb),
max_file_bytes: mib(self.file_mb()),
max_links: self.max_links,
compact_after: self.compact_after,
compact_bytes: mib(self.compact_mb),
core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() },
core: self.core(),
directory: DirectoryConfig {
max_plugins: self.max_plugins,
max_plugin_bytes: mib(self.max_plugin_mb),
max_total_bytes: mib(self.max_plugins_mb),
},
// At least 1 GiB, and room for two of the largest files (a compaction writes one beside
// the old one).
min_free_bytes: mib(self.file_mb().saturating_mul(2)).max(1 << 30),
}
}
/// The transport's part.
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
/// and a margin, not the core's 64 MiB.
fn open_message(&self) -> usize {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
/// The collaboration core's part: when open, messages as large as a file at most, and the
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
/// quarter of a file's bytes (256 KiB at least).
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
if !self.open_publish {
return base;
}
let max = self.open_message().min(base.max_message_bytes);
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
}
/// The transport's part. A private server keeps the defaults; open to the public, connections
/// per address are capped, messages are as large as a file at most, the bytes queued for a
/// client and for everyone and those waiting for the hub are bounded, one-shot requests leave
/// an eighth of the connections to sessions and are dropped after 20 idle seconds.
pub fn net(&self) -> NetLimits {
NetLimits { max_connections: self.max_connections, ..NetLimits::default() }
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
if !self.open_publish {
return base;
}
let max = self.open_message().min(base.max_client_message);
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
// eighth of each, and its biggest consumers are dropped first.
let (queued, reading) = (256usize << 20, 192usize << 20);
NetLimits {
max_per_address: self.max_connections_per_ip,
max_client_message: max,
max_queued_bytes: max.saturating_add(1 << 20),
max_queued_block: queued / 8,
max_queued_total: queued,
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
max_reading_total: reading,
max_in_flight: 128 << 20,
reserve_for_sessions: self.max_connections / 8,
request_idle: std::time::Duration::from_secs(20),
..base
}
}
}

View File

@ -0,0 +1,298 @@
//! The plugin directory: plugin bundles (`figcraft_plugin::bundle`) published to the server with
//! its key, listed, searched and fetched by anyone who can reach it, and unpublished with the key
//! (there are no accounts: whoever has the key is the publisher, and the operator moderates).
//!
//! ```text
//! <data>/plugins/index.json every version kept: manifest fields, size, SHA-256, when
//! <data>/plugins/<sha256>.bin a bundle, named by the SHA-256 of its bytes
//! ```
//!
//! A bundle is checked in full before it is kept (it decodes within the size cap, its manifest
//! parses and asks for a plugin API this build runs, its version is newer than the one kept, its
//! module compiles and imports only what the sandbox provides, its asset names are plain) and is
//! never run here. Bundles are named by their hash, so no name a publisher picks reaches the file
//! system, and a fetch reads the file back and checks its hash before sending it. Caps: bundle
//! size, versions kept and bytes kept; fetches share a budget of bytes per second, so a crowd of
//! downloads can't queue more than a bounded amount of data.
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use figcraft_collab::message::{PluginEntry, PluginReply, PluginReq};
use figcraft_collab_net::net::Bucket;
use figcraft_plugin::bundle::{Bundle, compare_versions, parse_version, sha256_hex};
use figcraft_plugin::manifest::Access;
use crate::disk;
use crate::store::now;
pub const PLUGINS: &str = "plugins";
pub const INDEX: &str = "index.json";
/// Largest `index.json` read, longest search, and most bytes of entries one listing carries.
const MAX_INDEX_FILE: u64 = 64 << 20;
const MAX_QUERY: usize = 200;
const MAX_LISTING: usize = 32 << 20;
/// What fetches may send, in MiB per second on average (each costs at least one).
const SERVE_MIB_PER_SEC: f64 = 32.0;
/// …and at once: eight times what one block of addresses may download at once, so no block can
/// take it all (see `abuse`).
const SERVE_BURST_MIB: f64 = 512.0;
const MIB: f64 = (1 << 20) as f64;
/// What the directory enforces.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DirectoryConfig {
/// Most versions kept (all plugins together); 0 turns the directory off.
pub max_plugins: usize,
/// Largest bundle.
pub max_plugin_bytes: u64,
/// Most bytes of bundles kept.
pub max_total_bytes: u64,
}
/// The plugins a server keeps.
pub struct Directory {
dir: PathBuf,
cfg: DirectoryConfig,
entries: Vec<PluginEntry>,
served: Bucket,
/// `index.json` couldn't be read: nothing is written over it until the operator fixes it.
broken: bool,
}
/// A plugin id as manifests have them (letters, digits, `.`, `-`, `_`; 1–128).
fn valid_id(s: &str) -> bool {
(1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
}
fn valid_sha(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Newest first.
fn by_version(a: &PluginEntry, b: &PluginEntry) -> Ordering {
compare_versions(&b.version, &a.version).unwrap_or(Ordering::Equal)
}
impl Directory {
/// The directory under `root` (made when the first plugin is published). Entries whose bundle
/// is missing or the wrong size are left out and reported, never deleted.
pub fn open(root: &Path, cfg: DirectoryConfig) -> (Directory, Vec<String>) {
let dir = root.join(PLUGINS);
let burst = (2 * cfg.max_plugin_bytes) as f64 / MIB;
let mut d = Directory { dir, cfg, entries: Vec::new(), served: Bucket::new(SERVE_MIB_PER_SEC, burst.max(SERVE_BURST_MIB)), broken: false };
let mut problems = Vec::new();
let index = d.dir.join(INDEX);
let _ = fs::remove_file(disk::temp_of(&index));
if !index.exists() {
return (d, problems);
}
let all: Vec<PluginEntry> =
match disk::read_capped(&index, MAX_INDEX_FILE).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())) {
Ok(all) => all,
Err(e) => {
problems.push(format!("plugin directory: {INDEX} can't be read ({e}); no plugins are listed or published until it is fixed"));
d.broken = true;
return (d, problems);
}
};
for e in all {
let size = fs::metadata(d.file(&e.sha256)).map(|m| m.len()).ok();
if !valid_id(&e.id) || parse_version(&e.version).is_none() || !valid_sha(&e.sha256) || size != Some(e.bytes) {
problems.push(format!(
"plugin directory: skipped {} {}: its bundle is missing or damaged",
e.id.escape_debug(),
e.version.escape_debug()
));
continue;
}
d.entries.push(e);
}
(d, problems)
}
fn file(&self, sha: &str) -> PathBuf {
self.dir.join(format!("{sha}.bin"))
}
/// Every version kept.
pub fn entries(&self) -> &[PluginEntry] {
&self.entries
}
/// Bytes of bundles kept (a bundle published under two versions counts once).
fn total_bytes(&self) -> u64 {
let files: BTreeMap<&str, u64> = self.entries.iter().map(|e| (e.sha256.as_str(), e.bytes)).collect();
files.values().sum()
}
/// Answer one request; `publisher`: the connection has the server's key.
pub fn handle(&mut self, publisher: bool, req: PluginReq) -> PluginReply {
let failed = |reason: String| PluginReply::Failed { reason };
if self.cfg.max_plugins == 0 {
return failed("this server's plugin directory is turned off".into());
}
let needs_key = || failed("publishing and unpublishing plugins needs the server's key (ws://host:port/?key=…)".into());
match req {
PluginReq::List { query } => PluginReply::List(self.list(&query)),
PluginReq::Fetch { id, version } => match self.fetch(&id, version.as_deref()) {
Ok((entry, bundle)) => PluginReply::Bundle { entry, bundle },
Err(e) => failed(e),
},
PluginReq::Publish { .. } | PluginReq::Unpublish { .. } if !publisher => needs_key(),
PluginReq::Publish { bundle } => self.publish(&bundle).map_or_else(failed, PluginReply::Published),
PluginReq::Unpublish { id, version } => self.unpublish(&id, version.as_deref()).map_or_else(failed, PluginReply::Removed),
}
}
/// The latest version of each plugin matching `query` (any case, in its id, name, description
/// or author), by name, with the versions kept.
pub fn list(&self, query: &str) -> Vec<PluginEntry> {
let q: String = query.trim().chars().take(MAX_QUERY).collect::<String>().to_lowercase();
let mut by_id: BTreeMap<&str, Vec<&PluginEntry>> = BTreeMap::new();
for e in &self.entries {
by_id.entry(e.id.as_str()).or_default().push(e);
}
let mut out: Vec<PluginEntry> = by_id
.into_values()
.filter_map(|mut versions| {
versions.sort_by(|a, b| by_version(a, b));
let mut latest = (*versions.first()?).clone();
latest.versions = versions.iter().map(|v| v.version.clone()).collect();
Some(latest)
})
.filter(|e| q.is_empty() || [&e.id, &e.name, &e.description, &e.author].iter().any(|f| f.to_lowercase().contains(&q)))
.collect();
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id)));
// A listing stays well within a message.
let mut size = 0;
out.retain(|e| {
size += 256 + e.name.len() + e.description.len() + e.author.len() + e.commands.iter().map(|c| c.len() + 8).sum::<usize>();
size <= MAX_LISTING
});
out
}
/// The size of version `version` (or the latest) of plugin `id`'s bundle, without reading it.
pub fn size_of(&self, id: &str, version: Option<&str>) -> Option<u64> {
self.entries
.iter()
.filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)))
.min_by(|a, b| by_version(a, b))
.map(|e| e.bytes)
}
/// Version `version` (or the latest) of plugin `id`, and its bundle, read back and checked
/// against its hash.
pub fn fetch(&mut self, id: &str, version: Option<&str>) -> Result<(PluginEntry, Vec<u8>), String> {
let entry = self
.entries
.iter()
.filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)))
.min_by(|a, b| by_version(a, b))
.cloned()
.ok_or_else(|| match version {
Some(v) => format!("the directory has no version {} of that plugin", v.chars().take(32).collect::<String>()),
None => "the directory has no such plugin (it may have been unpublished)".to_string(),
})?;
if !self.served.take_n(Instant::now(), 1.0 + entry.bytes as f64 / MIB) {
return Err("the server is busy sending plugins: try again in a moment".into());
}
let bytes = disk::read_capped(&self.file(&entry.sha256), self.cfg.max_plugin_bytes.max(entry.bytes))?;
if sha256_hex(&bytes) != entry.sha256 {
log::error!("plugin directory: the bundle of {} {} is damaged on disk; not sending it", entry.id, entry.version);
return Err("that plugin's bundle is damaged on the server; tell the server's owner".into());
}
Ok((entry, bytes))
}
/// Keep a bundle as a new version of its plugin, after checking all of it.
// shortcut: the bundle is checked (its module compiled) on the hub thread, like team libraries;
// move it to a worker if large plugins make other sessions wait noticeably.
pub fn publish(&mut self, bytes: &[u8]) -> Result<PluginEntry, String> {
let max = self.cfg.max_plugin_bytes;
if bytes.len() as u64 > max {
return Err(format!("the plugin is {} KiB; this server keeps plugins up to {} KiB", bytes.len() >> 10, max >> 10));
}
let bundle = Bundle::decode(bytes, usize::try_from(max).unwrap_or(usize::MAX))?;
let m = bundle.check()?;
if let Some(latest) = self.entries.iter().filter(|e| e.id == m.id).min_by(|a, b| by_version(a, b))
&& compare_versions(&m.version, &latest.version) != Some(Ordering::Greater)
{
return Err(format!("version {} of {} is on the server already: publish a newer version", latest.version, m.id));
}
if self.entries.len() >= self.cfg.max_plugins {
return Err(format!("the directory keeps at most {} plugin versions: unpublish some first", self.cfg.max_plugins));
}
let sha = sha256_hex(bytes);
let known = self.entries.iter().any(|e| e.sha256 == sha);
if !known && self.total_bytes() + bytes.len() as u64 > self.cfg.max_total_bytes {
return Err(format!("the directory is full ({} MiB): unpublish some plugins first", self.cfg.max_total_bytes >> 20));
}
let entry = PluginEntry {
id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
author: m.author.clone(),
api: m.api,
access: match m.permissions.access {
Access::Read => "read".into(),
Access::Write => "write".into(),
},
commands: m.permissions.commands.clone(),
widget: m.widget,
bytes: bytes.len() as u64,
sha256: sha.clone(),
published: now(),
versions: Vec::new(),
};
fs::create_dir_all(&self.dir).map_err(|e| format!("couldn't keep the plugin: {e}"))?;
let file = self.file(&sha);
if !known {
disk::write_atomic(&file, bytes).map_err(|e| format!("couldn't keep the plugin: {e}"))?;
}
self.entries.push(entry.clone());
if let Err(e) = self.save() {
self.entries.pop();
return Err(format!("couldn't keep the plugin: {e}"));
}
log::info!("plugin directory: published {} {} ({} bytes)", entry.id, entry.version, entry.bytes);
Ok(entry)
}
/// Remove version `version` (or every version) of plugin `id`; the versions removed.
pub fn unpublish(&mut self, id: &str, version: Option<&str>) -> Result<Vec<String>, String> {
let gone = |e: &PluginEntry| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal));
let (removed, kept): (Vec<PluginEntry>, Vec<PluginEntry>) = self.entries.iter().cloned().partition(gone);
if removed.is_empty() {
return Err("the directory has no such plugin or version".into());
}
let before = std::mem::replace(&mut self.entries, kept);
if let Err(e) = self.save() {
self.entries = before;
return Err(format!("couldn't unpublish: {e}"));
}
for r in &removed {
if !self.entries.iter().any(|e| e.sha256 == r.sha256)
&& let Err(e) = fs::remove_file(self.file(&r.sha256))
{
log::warn!("plugin directory: couldn't delete the bundle of {} {}: {e}", r.id, r.version);
}
}
let versions: Vec<String> = removed.into_iter().map(|e| e.version).collect();
log::info!("plugin directory: unpublished {id} {}", versions.join(", "));
Ok(versions)
}
fn save(&self) -> Result<(), String> {
if self.broken {
return Err(format!("the directory's {INDEX} is damaged; tell the server's owner"));
}
let text = serde_json::to_vec_pretty(&self.entries).map_err(|e| e.to_string())?;
disk::write_atomic(&self.dir.join(INDEX), &text).map_err(|e| e.to_string())
}
}

View File

@ -0,0 +1,264 @@
//! The plugin directory: publishing (checked in full, key only), listing and searching, fetching
//! (hash-checked, within a budget), unpublishing and its caps in the store; then end to end over
//! loopback: the app's commands publish a plugin folder, another session browses without the key,
//! installs with confirmation, runs, updates and removes it; the raw protocol refuses wrong keys and
//! stray messages; and the server's command line lists and moderates.
use std::path::Path;
use figcraft_collab::message::{ClientMsg, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg};
use figcraft_plugin::bundle::{Asset, Bundle, sha256_hex};
use serde_json::json;
use crate::cli::{self, Cli, Command};
use crate::directory::{DirectoryConfig, INDEX, PLUGINS};
use crate::store::{Store, StoreConfig};
use crate::test_util::{Raw, TempDir, config, key, session};
pub(crate) fn wasm() -> Vec<u8> {
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap()
}
pub(crate) fn manifest(id: &str, version: &str) -> String {
json!({"id": id, "name": "Tiny tool", "version": version, "author": "Ada Example", "description": "Does very little.", "main": "tiny.wasm",
"permissions": {"access": "write", "commands": ["shape.rectangle"]}})
.to_string()
}
fn bundle(id: &str, version: &str) -> Vec<u8> {
Bundle { manifest: manifest(id, version), wasm: wasm(), assets: vec![Asset { name: "icon.png".into(), data: vec![7; 10] }] }.encode()
}
fn cfg(dir: &Path, f: impl Fn(&mut DirectoryConfig)) -> StoreConfig {
let mut c = config(dir).store();
f(&mut c.directory);
c
}
const ID: &str = "org.example.tiny";
#[test]
fn publish_list_fetch_unpublish_and_restart() {
let dir = TempDir::new("plugins-store");
let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
let d = store.directory();
let first = d.publish(&bundle(ID, "1.0.0")).unwrap();
assert_eq!((first.name.as_str(), first.author.as_str(), first.access.as_str()), ("Tiny tool", "Ada Example", "write"));
assert_eq!(first.commands, ["shape.rectangle"]);
assert_eq!(first.sha256, sha256_hex(&bundle(ID, "1.0.0")));
assert!(d.publish(&bundle(ID, "1.0.0")).unwrap_err().contains("newer"));
assert!(d.publish(&bundle(ID, "0.9")).unwrap_err().contains("newer"));
d.publish(&bundle(ID, "1.10.0")).unwrap();
d.publish(&bundle("org.example.other", "2.0.0")).unwrap();
let all = d.list("");
assert_eq!(all.len(), 2);
let tiny = all.iter().find(|e| e.id == ID).unwrap();
assert_eq!((tiny.version.as_str(), tiny.versions.clone()), ("1.10.0", vec!["1.10.0".to_string(), "1.0.0".to_string()]));
assert_eq!(d.list("ADA EXAMPLE").len(), 2, "search covers the author, in any case");
assert!(d.list("nothing like it").is_empty());
let (e, bytes) = d.fetch(ID, None).unwrap();
assert_eq!((e.version.as_str(), sha256_hex(&bytes)), ("1.10.0", e.sha256.clone()));
assert_eq!(d.fetch(ID, Some("1.0")).unwrap().0.version, "1.0.0");
assert!(d.fetch(ID, Some("3.0.0")).is_err() && d.fetch("org.nope", None).is_err());
// Unpublishing one version deletes its bundle.
let file = dir.path().join(PLUGINS).join(format!("{}.bin", first.sha256));
assert!(file.exists());
assert_eq!(d.unpublish(ID, Some("1.0.0")).unwrap(), ["1.0.0"]);
assert!(!file.exists());
assert!(d.unpublish(ID, Some("1.0.0")).is_err());
drop(store);
// Kept across a restart; a bundle damaged on disk is never sent, a missing one is skipped.
let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
assert_eq!(store.directory().entries().len(), 2);
let latest = store.directory().fetch(ID, None).unwrap().0;
let path = dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256));
let mut damaged = std::fs::read(&path).unwrap();
let last = damaged.len() - 1;
damaged[last] ^= 0xFF;
std::fs::write(&path, &damaged).unwrap();
assert!(store.directory().fetch(ID, None).unwrap_err().contains("damaged"));
std::fs::write(&path, b"short").unwrap();
drop(store);
let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
assert!(store.problems().iter().any(|p| p.contains(ID) && p.contains("damaged")), "{:?}", store.problems());
drop(store);
// A damaged index lists nothing but deletes nothing.
std::fs::write(dir.path().join(PLUGINS).join(INDEX), b"{not json").unwrap();
let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
assert!(store.problems().iter().any(|p| p.contains(INDEX)));
let mut store = store;
assert!(store.directory().publish(&bundle(ID, "9.0.0")).unwrap_err().contains("damaged"), "nothing is written over a damaged index");
assert_eq!(std::fs::read(dir.path().join(PLUGINS).join(INDEX)).unwrap(), b"{not json");
assert!(dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256)).exists());
}
#[test]
fn bad_bundles_caps_and_keys() {
let dir = TempDir::new("plugins-bad");
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugin_bytes = 64 << 10)).unwrap();
let d = store.directory();
let with = |f: &dyn Fn(&mut Bundle)| {
let mut b = Bundle::decode(&bundle(ID, "1.0.0"), usize::MAX).unwrap();
f(&mut b);
b.encode()
};
let wasi = wat::parse_str(r#"(module (import "wasi_snapshot_preview1" "proc_exit" (func (param i32))) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
for (bytes, why) in [
(b"PK\x03\x04 a zip".to_vec(), "isn't"),
(vec![0u8; 70 << 10], "keeps plugins up to"),
(with(&|b| b.manifest = "{}".into()), "manifest"),
(with(&|b| b.manifest = manifest(ID, "1.0.0").replace("\"tiny.wasm\"", "\"tiny.wasm\", \"api\": 99")), "API"),
(with(&|b| b.manifest = manifest(ID, "latest")), "version"),
(with(&|b| b.wasm = b"not wasm".to_vec()), "WebAssembly"),
(with(&|b| b.wasm = wasi.clone()), "imports"),
(with(&|b| b.assets[0].name = "../../server.key".into()), "asset"),
(with(&|b| b.assets[0].name = "aux.png".into()), "asset"),
(with(&|b| b.assets[0].data = vec![0; 70 << 10]), "keeps plugins up to"),
] {
let e = d.publish(&bytes).unwrap_err();
assert!(e.contains(why), "{why}: {e}");
}
assert!(d.entries().is_empty());
// Only with the key.
assert!(matches!(d.handle(false, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Failed { reason } if reason.contains("key")));
assert!(matches!(d.handle(false, PluginReq::Unpublish { id: ID.into(), version: None }), PluginReply::Failed { .. }));
assert!(matches!(d.handle(true, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Published(_)));
assert!(matches!(d.handle(false, PluginReq::List { query: "x".repeat(100_000) }), PluginReply::List(_)));
// Fetches share a budget: a flood is turned away, not queued.
let busy = (0..800).filter_map(|_| d.fetch(ID, None).err()).filter(|e| e.contains("busy")).count();
assert!(busy > 0, "a flood of fetches hits the budget");
drop(store);
// Caps: versions kept, bytes kept, off.
let dir = TempDir::new("plugins-caps");
let size = bundle(ID, "1.0.0").len() as u64;
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 2)).unwrap();
store.directory().publish(&bundle(ID, "1.0.0")).unwrap();
store.directory().publish(&bundle(ID, "1.0.1")).unwrap();
assert!(store.directory().publish(&bundle(ID, "1.0.2")).unwrap_err().contains("at most 2"));
drop(store);
let dir = TempDir::new("plugins-full");
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_total_bytes = size + 10)).unwrap();
store.directory().publish(&bundle(ID, "1.0.0")).unwrap();
assert!(store.directory().publish(&bundle(ID, "1.0.1")).unwrap_err().contains("full"));
drop(store);
let dir = TempDir::new("plugins-off");
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 0)).unwrap();
assert!(
matches!(store.directory().handle(true, PluginReq::List { query: String::new() }), PluginReply::Failed { reason } if reason.contains("off"))
);
assert!(!dir.path().join(PLUGINS).exists());
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
/// A plugin folder: manifest, module in a subfolder, an asset.
fn folder(root: &Path, version: &str) -> std::path::PathBuf {
let dir = root.join(format!("tiny-{version}"));
std::fs::create_dir_all(dir.join("assets")).unwrap();
std::fs::write(dir.join("figcraft-plugin.json"), manifest(ID, version)).unwrap();
std::fs::write(dir.join("tiny.wasm"), wasm()).unwrap();
std::fs::write(dir.join("assets").join("icon.png"), [1, 2, 3]).unwrap();
dir
}
#[test]
fn publish_browse_install_update_and_remove_over_the_network() {
let dir = TempDir::new("plugins-net");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let with_key = format!("ws://{addr}/?key={}", key(dir.path()));
let plain = format!("ws://{addr}/");
// The publisher: the server's key.
let mut publisher = session("Ada");
let e = publisher.execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.0"), "server": plain})).unwrap_err().to_string();
assert!(e.contains("key"), "{e}");
let r = ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.0"), "server": with_key}));
assert_eq!(r["published"]["version"], "1.0.0");
let wrong = format!("ws://{addr}/?key=0123456789abcdef0123");
assert!(
publisher
.execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.1"), "server": wrong}))
.unwrap_err()
.to_string()
.contains("wrong server key")
);
// A user: browses without the key, installs only with confirmation, runs it.
let mut user = session("Bo");
let listed = ex(&mut user, "plugin.directory.list", json!({"server": plain}));
let p = &listed["plugins"][0];
assert_eq!((p["id"].as_str(), p["access"].as_str(), p["installed"].as_str()), (Some(ID), Some("write"), None));
assert!(user.execute("plugin.directory.install", &json!({"id": ID})).is_err());
let sha = p["sha256"].as_str().unwrap().to_string();
let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha}));
assert_eq!((r["version"].as_str(), r["source"].as_str()), (Some("1.0.0"), Some(plain.as_str())));
ex(&mut user, "plugin.run", json!({"id": ID}));
// An update appears and installs over it; browsing users can't unpublish.
ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.1"), "server": with_key}));
let listed = ex(&mut user, "plugin.directory.list", json!({}));
assert_eq!((listed["plugins"][0]["version"].as_str(), listed["plugins"][0]["update"].as_bool()), (Some("1.0.1"), Some(true)));
let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true}));
assert_eq!((r["version"].as_str(), r["replaced"].as_bool()), (Some("1.0.1"), Some(true)));
assert!(user.execute("plugin.directory.unpublish", &json!({"id": ID, "server": plain})).unwrap_err().to_string().contains("key"));
ex(&mut user, "plugin.remove", json!({"id": ID}));
let r = ex(&mut publisher, "plugin.directory.unpublish", json!({"id": ID, "server": with_key}));
assert_eq!(r["removed"], json!(["1.0.0", "1.0.1"]));
assert!(ex(&mut user, "plugin.directory.list", json!({}))["plugins"].as_array().unwrap().is_empty());
server.stop();
}
#[test]
fn the_raw_protocol_refuses_wrong_keys_and_stray_messages() {
let dir = TempDir::new("plugins-raw");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let open = |key: &str| ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: key.into(), client_version: String::new() });
let answer = |raw: &mut Raw| match raw.next() {
ServerMsg::Plugins(r) => *r,
other => panic!("not a directory answer: {other:?}"),
};
let mut raw = Raw::connect(addr);
raw.send(&open("not the key"));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("wrong server key")));
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL + 1, key: String::new(), client_version: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("protocol")));
let mut raw = Raw::connect(addr);
raw.send(&open(""));
assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: false });
raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") }));
assert!(matches!(answer(&mut raw), PluginReply::Failed { reason } if reason.contains("key")));
raw.send(&ClientMsg::Plugins(PluginReq::Fetch { id: "../../server.key".into(), version: Some("..".into()) }));
assert!(matches!(answer(&mut raw), PluginReply::Failed { .. }));
raw.send(&ClientMsg::Ping);
assert_eq!(raw.next(), ServerMsg::Pong);
raw.send(&ClientMsg::Admin(figcraft_collab::message::Admin::Files));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("plugin directory")));
let mut raw = Raw::connect(addr);
raw.send(&open(&key(dir.path())));
assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: true });
raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") }));
assert!(matches!(answer(&mut raw), PluginReply::Published(e) if e.id == ID));
server.stop();
}
#[test]
fn the_command_line_lists_and_moderates() {
let dir = TempDir::new("plugins-cli");
let run = |command: Command| cli::run(&Cli { config: config(dir.path()), command });
assert!(run(Command::Plugins).unwrap().contains("No plugins yet"));
Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle(ID, "1.0.0")).unwrap();
let table = run(Command::Plugins).unwrap();
assert!(table.contains(ID) && table.contains("Ada Example") && table.contains("write"), "{table}");
// Through the running server too.
let mut server = crate::start(&config(dir.path())).unwrap();
assert!(run(Command::Plugins).unwrap().contains(ID));
assert!(run(Command::RemovePlugin { id: ID.into(), version: Some("2.0.0".into()) }).is_err());
assert!(run(Command::RemovePlugin { id: ID.into(), version: None }).unwrap().contains("Unpublished"));
server.stop();
assert!(run(Command::Plugins).unwrap().contains("No plugins yet"));
let parsed = cli::parse(&["plugins".to_string(), "remove".into(), ID.into(), "--version".into(), "1.0.0".into()]).unwrap();
assert_eq!(parsed.command, Command::RemovePlugin { id: ID.into(), version: Some("1.0.0".into()) });
assert_eq!(cli::parse(&["--version".to_string()]).unwrap().command, Command::Version);
}

View File

@ -22,6 +22,8 @@ pub const SNAPSHOT: &str = "snapshot.bin";
pub const LOG: &str = "log.bin";
pub const META: &str = "meta.json";
pub const LINKS: &str = "links.json";
pub const LIBRARY: &str = "library.bin";
pub const USAGE: &str = "usage.json";
const SNAP_MAGIC: &[u8; 4] = b"FCSN";
const LOG_MAGIC: &[u8; 4] = b"FCLG";
@ -30,6 +32,19 @@ const SNAP_HEADER: usize = 4 + 1 + 8 + 4;
pub const LOG_HEADER: usize = 4 + 1 + 8;
const RECORD_HEADER: usize = 8;
/// Free space on the disk holding `path`, in bytes, where it can be told (Unix).
pub fn free_bytes(path: &Path) -> Option<u64> {
#[cfg(unix)]
{
rustix::fs::statvfs(path).ok().map(|s| s.f_bavail.saturating_mul(s.f_frsize))
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
/// `path` with `.tmp` added: where its next version is written first.
pub fn temp_of(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
@ -39,9 +54,27 @@ pub fn temp_of(path: &Path) -> PathBuf {
/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_as(path, bytes, false)
}
/// [`write_atomic`] for a secret: on Unix only the owner can read it, from the moment it exists.
pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_as(path, bytes, true)
}
fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
let tmp = temp_of(path);
{
let mut f = File::create(&tmp)?;
let mut o = fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
if private {
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
#[cfg(not(unix))]
let _ = private;
let mut f = o.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}

View File

@ -0,0 +1,802 @@
//! Open hosting against resource exhaustion: each test reproduces an attack from the security
//! review and checks what bounds it — a zip-bomb library (H1), a client that never reads its
//! replies (H2), a file growing past its cap through batches and a log that can't be written (H3),
//! usage reports that rewrote a file per request (M1), oversized messages and a busy hub (M2),
//! many addresses from one IPv6 block and one-shot requests taking every connection (M3), revoked
//! links on library connections (M4), data directory permissions (L6) and downloads per address
//! (L7).
use std::io::{Cursor, Write};
use std::net::{IpAddr, TcpListener};
use std::time::{Duration, Instant};
use figcraft_collab::message::{
Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReq, ServerMsg, Usage,
};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Client, Limits, NoRecompute, Outgoing, Replica, Server};
use figcraft_collab_net::hub::{Backend, Hosting};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_engine::Session;
use serde_json::json;
use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST};
use crate::config::Config;
use crate::public_tests::via_proxy;
use crate::store::{BUSY_DOWNLOADS, NO_PUBLIC_LIBRARY, Store};
use crate::test_util::{Raw, TempDir, config};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "abuse@example.com".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
/// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot
/// (deflated: a few KiB) and `padding` incompressible bytes stored beside it.
fn archive(bomb: usize, padding: usize) -> Vec<u8> {
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
s.execute("component.create", &json!({"ids": [f]})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let lib = s.doc().unwrap().published.clone().unwrap();
let bytes = figcraft_engine::library_server::archive(&lib).unwrap();
if bomb == 0 && padding == 0 {
return bytes;
}
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..src.len() {
let f = src.by_index_raw(i).unwrap();
if bomb > 0 && f.name() == "library/document.json" {
continue;
}
out.raw_copy_file(f).unwrap();
}
let opts = |m| zip::write::SimpleFileOptions::default().compression_method(m);
if bomb > 0 {
out.start_file("library/document.json", opts(zip::CompressionMethod::Deflated)).unwrap();
let chunk = vec![b' '; 1 << 20];
for _ in 0..bomb >> 20 {
out.write_all(&chunk).unwrap();
}
}
if padding > 0 {
out.start_file("padding.bin", opts(zip::CompressionMethod::Stored)).unwrap();
let mut rng = Rng::new(3);
out.write_all(&(0..padding).map(|_| rng.next_u64() as u8).collect::<Vec<u8>>()).unwrap();
}
out.finish().unwrap().into_inner()
}
fn open_library(link: &Invite) -> ClientMsg {
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: link.file.clone(), token: link.token.clone(), client_version: String::new() })
}
fn hosting() -> ClientMsg {
ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() })
}
/// Poll `f` until it holds or `secs` pass (bounded: never a fixed sleep for the outcome).
fn until(secs: u64, what: &str, mut f: impl FnMut() -> bool) {
let end = Instant::now() + Duration::from_secs(secs);
while !f() {
assert!(Instant::now() < end, "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(10));
}
}
/// A client in `server` (as the hub would let it in).
fn join(server: &mut Server) -> Client {
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
let (_, out) = server.join_as(h, Access::Edit).unwrap();
let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() };
Client::join(w).unwrap()
}
/// Rename the first page through `c`, as the server takes it.
fn rename(server: &mut Server, c: &mut Client, name: &str) {
let mut d = c.document().clone();
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().name = name.into();
let m = c.edit(&d).unwrap().unwrap();
for o in server.receive(c.id(), m) {
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
c.receive(m, &NoRecompute).unwrap();
}
}
}
// H1 -------------------------------------------------------------------------------------------
#[test]
fn a_zip_bomb_library_is_refused_before_it_unpacks() {
let dir = TempDir::new("bomb");
let mut c = config(dir.path());
c.max_file_mb = Some(1);
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (keyed, ..) = store.publish("Library", &snapshot("Library")).unwrap();
let bomb = archive(64 << 20, 0);
assert!(bomb.len() < 1 << 20, "a small archive ({} bytes) that unpacks to 64 MiB", bomb.len());
let e = store.publish_library(&keyed.id, &bomb).unwrap_err();
assert!(e.contains("too large"), "{e}");
assert_eq!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap(), 1, "a real library still goes in");
drop(store);
// Open to the public: a file published without the key holds no library at all.
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
let (mine, ..) = store.publish_open(ip("203.0.113.5"), "Mine", &snapshot("Mine")).unwrap();
assert_eq!(store.publish_library(&mine.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY);
assert_ne!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY, "the operator's files still can");
}
// H2 -------------------------------------------------------------------------------------------
#[test]
fn a_client_that_never_reads_can_queue_only_so_much() {
let dir = TempDir::new("noread");
let cfg = Config { max_file_mb: Some(2), max_plugin_mb: 1, ..open(dir.path()) };
let cap = cfg.net().max_queued_bytes;
assert_eq!(cap, 4 << 20, "the largest message and a margin");
let view = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 1_500_000)).unwrap();
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
};
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&open_library(&view));
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Ready { .. })));
// Ask for the 1.5 MB library over and over without reading an answer.
for _ in 0..100 {
if raw.try_send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None })).is_err() {
break;
}
}
let mut most = 0;
let end = Instant::now() + Duration::from_secs(3);
while Instant::now() < end {
most = most.max(server.queued_bytes());
std::thread::sleep(Duration::from_millis(5));
}
assert!(most <= cap, "{most} bytes were queued for one client (cap {cap})");
// Its queue is let go once it is dropped.
until(15, "the stuck client's queue to be freed", || server.queued_bytes() == 0);
server.stop();
}
// H3 -------------------------------------------------------------------------------------------
#[test]
fn a_file_cant_grow_past_its_cap_through_batches() {
let dir = TempDir::new("grow");
let cfg = Config { max_file_mb: Some(1), max_plugin_mb: 1, ..open(dir.path()) };
let max = 1u64 << 20;
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let (info, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Grow".into(), snapshot: snapshot("Grow") }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(info.open);
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
let mut raw = Raw::connect(addr);
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
let welcome = loop {
if let w @ ServerMsg::Welcome { .. } = raw.next() {
break w;
}
};
let mut client = Client::join(welcome).unwrap();
// Each batch adds a layer with a 200 KB name: the file grows until the server refuses.
let mut refused = None;
for i in 0..12 {
let mut d = client.document().clone();
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, &format!("{i}{}", "x".repeat(200_000)), 0.0, 0.0, 10.0, 10.0).unwrap();
let Some(m) = client.edit(&d).unwrap() else { continue };
raw.send(&m);
let answer = loop {
if let m @ (ServerMsg::Changes(_) | ServerMsg::Reject { .. }) = raw.next() {
break m;
}
};
if let ServerMsg::Reject { reason, .. } = &answer {
refused = Some(reason.clone());
}
client.receive(answer, &NoRecompute).unwrap();
if refused.is_some() {
break;
}
}
let reason = refused.expect("the server refused a batch once the file was full");
assert!(reason.contains("reached the server's limit of 1 MiB"), "{reason}");
let on_disk: u64 = ["snapshot.bin", "log.bin"]
.iter()
.filter_map(|n| std::fs::metadata(dir.path().join("files").join(&file.id).join(n)).ok())
.map(|m| m.len())
.sum();
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
drop(raw);
server.stop();
// Everything confirmed opens again with the same settings, after compacting on the way out.
let kept = client.document().nodes().count();
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
let welcome = loop {
match raw.next() {
w @ ServerMsg::Welcome { .. } => break w,
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
_ => {}
}
};
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
server.stop();
}
#[test]
fn the_sessions_memory_of_batches_is_bounded_in_bytes() {
let mut server = Server::new(Document::new("Log"), "", Limits { max_log_bytes: 100_000, ..Limits::default() });
let mut c = join(&mut server);
for i in 0..20 {
rename(&mut server, &mut c, &format!("{i}{}", "y".repeat(30_000)));
assert!(server.log_bytes() <= 100_000, "{} bytes kept", server.log_bytes());
}
assert!(server.log_bytes() > 0, "recent batches are still kept");
}
#[test]
fn changes_that_cant_be_saved_are_never_confirmed() {
let dir = TempDir::new("unsaved");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (info, ..) = store.publish("Durable", &snapshot("Durable")).unwrap();
let mut server = Backend::open(&mut store, &info.id).unwrap();
let mut c = join(&mut server);
rename(&mut server, &mut c, "Kept");
Backend::changed(&mut store, &info.id, &mut server);
assert!(Backend::flush(&mut store).is_empty());
rename(&mut server, &mut c, "Lost");
Backend::changed(&mut store, &info.id, &mut server);
store.break_log(&info.id);
assert_eq!(Backend::flush(&mut store), vec![info.id.clone()], "the hub hears it couldn't be saved");
// The file opens again from what is on disk: the saved change, not the unsaved one.
drop(server);
let mut server = Backend::open(&mut store, &info.id).unwrap();
let c = join(&mut server);
let page = c.document().first_page().unwrap();
assert_eq!(c.document().node(page).unwrap().name, "Kept");
}
// M1 -------------------------------------------------------------------------------------------
#[test]
fn usage_reports_dont_rewrite_a_file_per_request() {
let dir = TempDir::new("usage");
let cfg = config(dir.path());
let view = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 0)).unwrap();
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
};
let usage_file = dir.path().join("files").join(&view.file).join("usage.json");
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&open_library(&view));
raw.next();
for i in 0..50u32 {
let usage = Usage { file: format!("User {}", i % 7), components: vec![(NodeId(2), i + 1)] };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(1), usage: Some(usage) }));
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Unchanged { .. })));
}
assert!(!usage_file.exists(), "kept in memory, not written 50 times");
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let ServerMsg::Library(r) = raw.next() else { panic!() };
let LibraryReply::Usage(files) = *r else { panic!() };
assert_eq!(files.len(), 7);
server.stop();
assert!(usage_file.exists(), "written when the server stops");
}
// M2 -------------------------------------------------------------------------------------------
/// Panics in the server's connection threads (`live-…`) since the hook went in: they mustn't
/// happen, but a thread's panic doesn't fail a test by itself.
static CONNECTION_PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn count_connection_panics() {
static HOOK: std::sync::Once = std::sync::Once::new();
HOOK.call_once(|| {
let before = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().name().is_some_and(|n| n.starts_with("live-")) {
CONNECTION_PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
before(info);
}));
});
}
#[test]
fn messages_are_capped_by_what_the_connection_is_for() {
count_connection_panics();
let dir = TempDir::new("caps");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
// Browsing a plugin directory takes small requests only; the oversized frame is refused and
// the connection closed cleanly (its reader isn't asked for the rest of that frame).
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: String::new() }));
raw.next();
raw.send_header(200_000);
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("too large") && reason.contains("KiB")));
drop(raw);
// A joined client whose batch is bigger than a file may be is told the limit.
let (_, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Small".into(), snapshot: snapshot("Small") }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
let mut joined = Raw::connect(addr);
joined.send(&Client::hello(&file.id, &edit.token, "Big", ""));
while !matches!(joined.next(), ServerMsg::Welcome { .. }) {}
joined.send_header(40 << 20);
let reason = loop {
if let ServerMsg::Refused { reason } = joined.next() {
break reason;
}
};
assert!(reason.contains("at most 17 MiB"), "{reason}");
drop(joined);
until(10, "the refused connections to close", || server.connections() == 0);
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0, "a connection thread panicked");
// Open to the public, a message is at most a file's size (and a margin), not 64 MiB.
assert_eq!(open(dir.path()).net().max_client_message, 17 << 20);
server.stop();
// Past the bytes the hub may have waiting, a connection is dropped as busy.
let cfg = open(dir.path());
let store = Store::open(dir.path(), cfg.store()).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut busy = Hosting::serve(listener, store, NetLimits { max_in_flight: 10_000, ..cfg.net() }).unwrap();
let mut raw = Raw::connect(busy.addr());
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
let mut big = Document::new("Big");
let page = big.first_page().unwrap();
big.create(page, NodeKind::Rectangle, &"z".repeat(30_000), 0.0, 0.0, 1.0, 1.0).unwrap();
raw.send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
busy.stop();
}
// M3 -------------------------------------------------------------------------------------------
#[test]
fn one_ipv6_block_gets_rising_but_bounded_limits() {
let dir = TempDir::new("blocks");
let cfg = Config { max_connections_per_ip: 1, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut held = Vec::new();
// Two /64s of one /56 fit (2 × the cap), the third doesn't.
for n in 1..=2 {
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{n}::1"));
assert!(matches!(r, ServerMsg::Hosting(_)), "/64 number {n}");
held.push(ws);
}
assert!(matches!(via_proxy(addr, "2001:db8:1:3::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")));
// Four of one /48 fit (4 × the cap) — two of them taken above — the fifth doesn't.
for n in 1..=2u16 {
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{:x}::1", n << 8));
assert!(matches!(r, ServerMsg::Hosting(_)), "/56 number {n}");
held.push(ws);
}
assert!(matches!(via_proxy(addr, "2001:db8:1:300::1").1, ServerMsg::Refused { .. }), "the /48 is full");
assert!(matches!(via_proxy(addr, "2001:db8:2::1").1, ServerMsg::Hosting(_)), "another /48 isn't");
drop(held);
server.stop();
}
#[test]
fn rates_count_per_block_and_publishing_for_everyone() {
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
// One connection a minute per /64, two per /56, four per /48.
let in_one_56 = (0..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, *n, 0, 0, 0, 0]), Action::Connect, t)).count();
assert_eq!(in_one_56, 2);
let in_one_48 = (1..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, n << 8, 0, 0, 0, 0]), Action::Connect, t)).count();
assert_eq!(in_one_48, 2, "the /48's four, two of them taken above");
// Publishing: one an hour per address, 32 for everyone together.
let published = (0..60u32).filter(|n| a.allow(IpAddr::from((0xC000_0200 + n).to_be_bytes()), Action::Publish, t)).count();
assert_eq!(published, 32);
}
#[test]
fn one_shot_requests_leave_connections_for_sessions() {
let dir = TempDir::new("reserve");
let cfg = Config { max_connections: 8, ..open(dir.path()) };
assert_eq!(cfg.net().reserve_for_sessions, 1);
let edit = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, edit, _) = store.publish("Team", &snapshot("Team")).unwrap();
(file.id, edit.token)
};
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut held = Vec::new();
for _ in 0..7 {
let mut raw = Raw::connect(addr);
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
held.push(raw);
}
let mut eighth = Raw::connect(addr);
eighth.send(&hosting());
assert!(matches!(eighth.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
drop(eighth);
until(5, "the refused connection to close", || server.connections() <= 7);
let mut session = Raw::connect(addr);
session.send(&Client::hello(&edit.0, &edit.1, "Ada", ""));
assert!(matches!(session.next(), ServerMsg::Welcome { .. }), "a session still gets in");
server.stop();
}
// M4 -------------------------------------------------------------------------------------------
#[test]
fn revoking_or_removing_cuts_library_connections_too() {
let dir = TempDir::new("libkick");
let cfg = config(dir.path());
let (file, view) = {
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 0)).unwrap();
(file.id, view)
};
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let invite = Invite { server: Endpoint::plain("x"), file: file.clone(), token: view.token.clone() };
let mut lib = Raw::connect(addr);
lib.send(&open_library(&invite));
lib.next();
let key = crate::test_util::key(dir.path());
manage::request(
&Endpoint::plain(addr.to_string()),
&key,
vec![Admin::Revoke { file: file.clone(), link: view.link.id.clone() }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("revoked")));
// A library connection with another link is cut when the file is removed.
let made = manage::request(
&Endpoint::plain(addr.to_string()),
&key,
vec![Admin::CreateLink { file: file.clone(), access: Access::View, label: String::new() }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
let Some(AdminReply::Link(l)) = made.into_iter().next() else { panic!() };
let mut lib = Raw::connect(addr);
lib.send(&open_library(&Invite { token: l.token, ..invite }));
lib.next();
manage::request(&Endpoint::plain(addr.to_string()), &key, vec![Admin::Remove { file }], &NetLimits::default(), Duration::from_secs(5)).unwrap();
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("removed")));
server.stop();
}
// L6, L7 ---------------------------------------------------------------------------------------
#[cfg(unix)]
#[test]
fn only_the_server_reads_its_data_directory_and_key() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("perms");
let data = dir.path().join("data");
let store = Store::open(&data, config(&data).store()).unwrap();
drop(store);
assert_eq!(std::fs::metadata(&data).unwrap().permissions().mode() & 0o777, 0o700);
assert_eq!(std::fs::metadata(data.join("server.key")).unwrap().permissions().mode() & 0o777, 0o600);
}
#[test]
fn downloads_have_a_share_per_address() {
let mut a = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
let me = ip("198.51.100.1");
let chunk = 16 << 20;
let served = (0..10).filter(|_| a.allow(me, Action::Download(chunk), t)).count();
assert_eq!(served, (DOWNLOAD_BURST as usize) / chunk, "a burst, then wait");
assert!(a.allow(ip("198.51.100.2"), Action::Download(chunk), t), "another address has its own share");
// A team library is served within it, then refused, saying why; another address still gets it.
let dir = TempDir::new("libshare");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 4 << 20)).unwrap();
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
let served = (0..40).take_while(|_| matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Library { .. })).count();
assert!((3..=5).contains(&served), "{served} downloads of 4 MiB in a burst of 16 MiB");
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Failed { reason } if reason == BUSY_DOWNLOADS));
assert!(matches!(fetch(&mut store, "198.51.100.9"), LibraryReply::Library { .. }));
}
// Second review: N2, L7, N3, M3, N5, N7 ------------------------------------------------------
#[test]
fn a_file_at_its_cap_is_compacted_at_most_once_a_minute() {
let dir = TempDir::new("forced");
let mut c = config(dir.path());
c.max_file_mb = Some(1);
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (info, ..) = store.publish("Full", &snapshot("Full")).unwrap();
let mut server = Backend::open(&mut store, &info.id).unwrap();
let mut client = join(&mut server);
let before = store.compactions();
// Alternate a small accepted batch with one that would take the file over its cap.
for i in 0..50 {
rename(&mut server, &mut client, &format!("page {i}"));
Backend::changed(&mut store, &info.id, &mut server);
assert!(Backend::room(&mut store, &info.id, &server, 2 << 20).is_err());
}
assert!(store.compactions() - before <= 1, "{} compactions for 50 over-cap batches", store.compactions() - before);
}
/// A plugin directory with one plugin whose bundle is about `mib` MiB.
fn plugin_store(dir: &std::path::Path, mib: usize) -> Store {
let mut store = Store::open(dir, config(dir).store()).unwrap();
let bundle = figcraft_plugin::bundle::Bundle {
manifest: crate::directory_tests::manifest("org.example.big", "1.0.0"),
wasm: crate::directory_tests::wasm(),
assets: vec![figcraft_plugin::bundle::Asset { name: "data.bin".into(), data: vec![9; mib << 20] }],
};
store.directory().publish(&bundle.encode()).unwrap();
store
}
#[test]
fn one_address_cant_spend_everyones_plugin_downloads() {
let dir = TempDir::new("plugshare");
let mut store = plugin_store(dir.path(), 4);
let fetch = || PluginReq::Fetch { id: "org.example.big".into(), version: None };
let greedy = ip("198.51.100.1");
// Its share runs out after a few; then 200 more tries cost nobody else anything.
let served =
(0..10).take_while(|_| matches!(store.plugins(greedy, false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. })).count();
assert!((2..=5).contains(&served), "{served}");
for _ in 0..200 {
let r = store.plugins(greedy, false, fetch());
assert!(matches!(&r, figcraft_collab::message::PluginReply::Failed { reason } if reason == BUSY_DOWNLOADS));
}
assert!(
matches!(store.plugins(ip("198.51.100.2"), false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. }),
"others still download"
);
}
#[test]
fn half_read_messages_count_against_a_reading_budget() {
let dir = TempDir::new("reading");
let cfg = open(dir.path());
let store = Store::open(dir.path(), cfg.store()).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut busy = Hosting::serve(listener, store, NetLimits { max_reading_block: 64 << 10, max_reading_total: 1 << 20, ..cfg.net() }).unwrap();
let mut raw = Raw::connect(busy.addr());
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)), "small messages are read");
let mut big = Document::new("Big");
let page = big.first_page().unwrap();
big.create(page, NodeKind::Rectangle, &"r".repeat(200_000), 0.0, 0.0, 1.0, 1.0).unwrap();
let _ = raw.try_send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy reading")));
until(10, "the reading budget to be given back", || busy.connections() == 0);
// Given back: another address reads again.
let mut raw = Raw::connect(busy.addr());
raw.send(&hosting());
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
busy.stop();
}
#[test]
fn one_ipv6_block_takes_at_most_an_eighth_of_the_connections() {
let dir = TempDir::new("eighth");
let cfg = Config { max_connections: 64, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut held = Vec::new();
for n in 1..=8u16 {
let (ws, r) = via_proxy(addr, &format!("2001:db8:9:{:x}::1", n << 8));
assert!(matches!(r, ServerMsg::Hosting(_)), "connection {n}");
held.push(ws);
}
assert!(matches!(via_proxy(addr, "2001:db8:9:900::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")), "64 / 8");
assert!(matches!(via_proxy(addr, "2001:db8:a::1").1, ServerMsg::Hosting(_)), "another /48 still gets in");
drop(held);
server.stop();
}
#[test]
fn no_block_can_use_up_a_shared_budget() {
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
let t = Instant::now();
// A /48 publishes at most an eighth of what everyone may; everyone else keeps the rest.
let from_48 = (0..200u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 5, n << 8, 0, 0, 0, 0]), Action::Publish, t)).count();
assert_eq!(from_48, 40, "four addresses' worth");
let others = (0..400u32).filter(|n| a.allow(IpAddr::from((0xC633_6400 + n).to_be_bytes()), Action::Publish, t)).count();
assert_eq!(others, 320 - 40, "the rest of everyone's 320 an hour");
// Downloads: a /48's burst is at most an eighth of everyone's (checked when compiling, in `store`).
}
#[test]
fn problems_stay_bounded_and_a_full_disk_refuses_writes() {
let dir = TempDir::new("problems");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (info, ..) = store.publish("Broken", &snapshot("Broken")).unwrap();
std::fs::write(dir.path().join("files").join(&info.id).join("snapshot.bin"), b"junk").unwrap();
for _ in 0..500 {
assert!(Backend::open(&mut store, &info.id).is_err());
}
assert!(store.problems().len() <= 200, "{} problems kept", store.problems().len());
drop(store);
// Where the free space can be told, below the threshold nothing more is written.
if crate::disk::free_bytes(dir.path()).is_some() {
let mut c = config(dir.path()).store();
c.min_free_bytes = u64::MAX;
let mut store = Store::open(dir.path(), c).unwrap();
let e = store.publish("More", &snapshot("More")).unwrap_err();
assert!(e.contains("disk is nearly full"), "{e}");
}
}
// Third review: R1, N3, R2 -------------------------------------------------------------------
/// Read messages until `pick` takes one (others in between are skipped).
fn wait_for<T>(raw: &mut Raw, what: &str, mut pick: impl FnMut(ServerMsg) -> Option<T>) -> T {
loop {
match raw.next() {
ServerMsg::Refused { reason } => panic!("refused while waiting for {what}: {reason}"),
m => {
if let Some(t) = pick(m) {
return t;
}
}
}
}
}
#[test]
fn an_office_behind_one_address_works_together() {
let dir = TempDir::new("office");
let cfg = Config { max_file_mb: Some(4), max_plugin_mb: 1, ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
// A file near the size limit.
let mut d = Document::new("Office");
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, &"o".repeat(3_000_000), 0.0, 0.0, 10.0, 10.0).unwrap();
let (_, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Office".into(), snapshot: Replica::new(d).snapshot().unwrap() }],
&NetLimits::default(),
Duration::from_secs(10),
)
.unwrap();
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
// Twenty colleagues, one address, all joining at once: twenty welcomes of 3 MB.
let mut people: Vec<Raw> = (0..20).map(|_| Raw::connect(addr)).collect();
for (i, p) in people.iter_mut().enumerate() {
p.send(&Client::hello(&file.id, &edit.token, &format!("Person {i}"), ""));
}
let mut clients: Vec<Client> = people
.iter_mut()
.map(|p| Client::join(wait_for(p, "the welcome", |m| matches!(m, ServerMsg::Welcome { .. }).then_some(m))).unwrap())
.collect();
// Everyone moves their cursor (twenty people at ~30 a second share the address's budget).
for p in people.iter_mut() {
for k in 0..60 {
p.send(&ClientMsg::Presence(figcraft_collab::Presence { cursor: Some((page, f64::from(k), 0.0)), ..Default::default() }));
}
}
// One pastes 1 MB: the broadcast reaches all twenty.
let mut doc = clients[0].document().clone();
doc.create(page, NodeKind::Rectangle, &"p".repeat(1_000_000), 0.0, 0.0, 5.0, 5.0).unwrap();
let batch = clients[0].edit(&doc).unwrap().unwrap();
people[0].send(&batch);
for (i, p) in people.iter_mut().enumerate() {
let seq = wait_for(p, "the paste", |m| {
if let ServerMsg::Changes(a) = m {
a.changes.iter().any(|c| matches!(c, figcraft_collab::Change::Create { .. })).then_some(a.seq)
} else {
None
}
});
assert!(seq > 0, "person {i}");
}
// Still all connected.
for p in people.iter_mut() {
p.send(&ClientMsg::Ping);
wait_for(p, "the pong", |m| (m == ServerMsg::Pong).then_some(()));
}
assert_eq!(server.connections(), 20);
drop(clients.drain(..));
server.stop();
}
#[test]
fn the_biggest_reader_is_cut_off_when_everyones_reading_budget_runs_out() {
count_connection_panics();
let dir = TempDir::new("readers");
let cfg = open(dir.path());
let store = Store::open(dir.path(), cfg.store()).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let limits = NetLimits { max_reading_block: 10 << 20, max_reading_total: 300_000, ..cfg.net() };
let mut busy = Hosting::serve(listener, store, limits).unwrap();
let addr = busy.addr();
// A slow sender: it starts a 300 KB message, sends 250 KB of it and stops.
let mut hog = Raw::connect(addr);
hog.send(&hosting());
assert!(matches!(hog.next(), ServerMsg::Hosting(_)));
let mut frame = vec![0x82, 0x80 | 127];
frame.extend_from_slice(&300_000u64.to_be_bytes());
frame.extend_from_slice(&[1, 2, 3, 4]);
frame.extend(std::iter::repeat_n(0x55, 250_000));
hog.raw_write(&frame);
until(10, "the server to read the hog's bytes", || busy.reading_bytes() >= 250_000);
// Someone else sends a 100 KB message: the hog is cut off, not them.
let mut other = Raw::connect(addr);
other.send(&hosting());
assert!(matches!(other.next(), ServerMsg::Hosting(_)));
let mut d = Document::new("Other");
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, &"q".repeat(100_000), 0.0, 0.0, 1.0, 1.0).unwrap();
other.send(&ClientMsg::Admin(Admin::Publish { name: "Other".into(), snapshot: Replica::new(d).snapshot().unwrap() }));
assert!(matches!(other.next(), ServerMsg::Admin(_)), "the message was read and answered");
assert!(hog.closed_within(Duration::from_secs(10)), "the hog's connection was cut");
until(10, "the hog's bytes to be given back", || busy.reading_bytes() == 0);
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0);
busy.stop();
}
#[test]
fn the_server_forgets_addresses_whose_connections_are_gone() {
let dir = TempDir::new("forget");
let cfg = Config { trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
for n in 1..=10u8 {
let (ws, r) = via_proxy(addr, &format!("203.0.113.{n}"));
assert!(matches!(r, ServerMsg::Hosting(_)));
drop(ws);
}
until(10, "the connections to close", || server.connections() == 0);
let (_last, _) = via_proxy(addr, "203.0.113.200");
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
server.stop();
}

View File

@ -8,8 +8,11 @@
//! that was confirmed and clients resume where they were. There are no user accounts yet: share
//! links are the access, and the server key manages files.
pub mod abuse;
pub mod cli;
pub mod codes;
pub mod config;
pub mod directory;
pub mod disk;
pub mod store;
@ -37,6 +40,16 @@ impl Running {
self.hosting.connections()
}
/// Bytes waiting to be sent to clients, all connections together.
pub fn queued_bytes(&self) -> usize {
self.hosting.queued_bytes()
}
/// Blocks of addresses the server keeps counters for (see [`Hosting::blocks_remembered`]).
pub fn blocks_remembered(&self) -> usize {
self.hosting.blocks_remembered()
}
/// Stop (see the type's docs).
pub fn stop(&mut self) {
self.hosting.stop();
@ -50,6 +63,9 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
for p in store.problems() {
log::warn!("{p}");
}
for w in cfg.warnings() {
log::warn!("{w}");
}
let files = store.len();
let addr = cfg.addr()?;
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
@ -64,15 +80,29 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
cfg.data.display(),
public.url()
);
if addr.ip().is_loopback() {
if addr.ip().is_loopback() && cfg.public_url.is_none() {
log::info!("only this machine can connect: listen on 0.0.0.0:{} (or behind a reverse proxy) to share", addr.port());
}
Ok(Running { hosting })
}
#[cfg(test)]
mod background_tests;
#[cfg(test)]
mod cli_tests;
#[cfg(test)]
mod codes_tests;
#[cfg(test)]
mod directory_tests;
#[cfg(test)]
mod hardening_tests;
#[cfg(test)]
mod library_tests;
#[cfg(test)]
mod owned_tests;
#[cfg(test)]
mod public_tests;
#[cfg(test)]
mod server_tests;
#[cfg(test)]
mod store_tests;

View File

@ -0,0 +1,147 @@
//! Team libraries end to end over loopback, with the server in-process on a temporary data
//! directory: a file publishes its library to the server, another file enables it with a view
//! link, places a component and overrides it; the library is republished with a change, the other
//! file's app notices in the background and accepts the update (the override stays); usage counts;
//! view links can't publish; junk library requests are refused and the server keeps serving.
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, OpenLibrary, PROTOCOL, ServerMsg, Usage};
use figcraft_collab::sim::Rng;
use figcraft_collab_net::Invite;
use figcraft_collab_net::hub::Backend;
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::store::Store;
use crate::test_util::{Raw, TempDir, config, publish, pump, session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
match s.execute(cmd, &p) {
Ok(v) => v,
Err(e) => panic!("{cmd} {p}: {e}"),
}
}
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn fill(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn open(link: &Invite) -> ClientMsg {
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: link.file.clone(), token: link.token.clone(), client_version: String::new() })
}
fn answer(raw: &mut Raw) -> LibraryReply {
match raw.next() {
ServerMsg::Library(r) => *r,
other => panic!("not a library answer: {other:?}"),
}
}
#[test]
fn publish_enable_update_and_usage() {
let dir = TempDir::new("library");
let cfg = config(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
// Ada's library file, kept on the server: a "Button" with a "Label".
let mut a = session("Ada");
let f = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
ex(&mut a, "text.create", json!({"text": "OK", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
ex(&mut a, "node.set", json!({"id": f, "props": {"fill": "#0000ff"}}));
let button = id(&ex(&mut a, "component.create", json!({"ids": [f]})));
let p = publish(&mut a, dir.path(), addr);
let mut b = session("Bo");
let e = b.execute("library.add", &json!({"path": p.view})).unwrap_err().to_string();
assert!(e.contains("no library on the server"), "{e}");
let r = ex(&mut a, "library.publishToServer", json!({}));
assert_eq!(r["version"], 1);
// Bo enables it with the view link and places the Button, overriding its label.
let r = ex(&mut b, "library.add", json!({"path": p.view}));
let lid = r["id"].as_str().unwrap().to_string();
assert_eq!(r["components"].as_array().map(Vec::len), Some(1));
let inst = id(&ex(&mut b, "library.place", json!({"library": lid, "component": "Button", "x": 0, "y": 0})));
let label = b.doc().unwrap().node(inst).unwrap().children[0];
ex(&mut b, "text.set", json!({"id": label, "characters": "Buy"}));
assert_eq!(fill(&b, inst), "0000FF");
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["server"], true);
// Ada changes the Button and publishes again; nothing more to publish after that.
ex(&mut a, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}}));
assert_eq!(ex(&mut a, "library.publishToServer", json!({}))["version"], 2);
let e = a.execute("library.publishToServer", &json!({})).unwrap_err().to_string();
assert!(e.contains("no changes"), "{e}");
// Bo's app notices in the background ("Library updates available") and takes the update.
b.refresh_server_libraries();
pump(&mut [&mut b], "the new version", |s| s[0].libraries.cached(&p.view).is_some_and(|l| l.is_ok_and(|l| l.manifest.version == 2)));
assert_eq!(ex(&mut b, "library.updates", json!({}))["count"], 1);
assert_eq!(ex(&mut b, "library.acceptUpdates", json!({}))["components"], 1);
assert_eq!(fill(&b, inst), "00FF00", "the change arrived");
let label = b.doc().unwrap().node(inst).unwrap().children[0];
assert_eq!(b.doc().unwrap().node(label).unwrap().kind.text().map(|t| t.characters.as_str()), Some("Buy"), "the override stayed");
// Usage: what Bo's app reported, seen by Ada (her file's library) and by Bo (by the library).
for (s, args) in [(&mut a, json!({})), (&mut b, json!({"library": lid}))] {
let u = ex(s, "library.usage", args);
assert_eq!(u["instances"], 1, "{u}");
assert_eq!(u["files"].as_array().map(Vec::len), Some(1), "{u}");
assert_eq!(u["components"][0]["name"], "Button", "{u}");
assert_eq!(u["components"][0]["files"], 1, "{u}");
}
// A view link can't publish, whatever the app does.
let mine = id(&ex(&mut b, "frame.create", json!({"x": 300, "y": 0, "width": 10, "height": 10, "name": "Mine"})));
ex(&mut b, "component.create", json!({"ids": [mine]}));
let e = b.execute("library.publishToServer", &json!({"server": p.view})).unwrap_err().to_string();
assert!(e.contains("only view"), "{e}");
let view = Invite::parse(&p.view).unwrap();
let mut raw = Raw::connect(addr);
raw.send(&open(&view));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::View });
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
let edit = Invite::parse(&p.edit).unwrap();
let mut raw = Raw::connect(addr);
raw.send(&open(&edit));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
let mut rng = Rng::new(5);
for round in 0..12 {
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
for _ in 0..1 + rng.below(8) {
let i = rng.below(junk.len());
junk[i] = rng.next_u64() as u8;
}
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
}
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
let mut raw = Raw::connect(addr);
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
// Kept on disk: the library is there after the server stops.
server.stop();
drop(server);
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
assert!(matches!(
store.library(std::net::Ipv4Addr::LOCALHOST.into(), &edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
LibraryReply::Library { version: 2, .. }
));
}

View File

@ -26,15 +26,29 @@ fn main() -> ExitCode {
};
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).target(env_logger::Target::Stdout).init();
match figcraft_server::start(&cli.config) {
// Serves until the process is stopped: what was confirmed is on disk already, and clients
// reconnect once it runs again.
Ok(_running) => loop {
std::thread::park();
},
let mut running = match figcraft_server::start(&cli.config) {
Ok(r) => r,
Err(e) => {
log::error!("{e}");
ExitCode::FAILURE
return ExitCode::FAILURE;
}
};
// Serves until SIGTERM, SIGINT or SIGHUP (Ctrl+C or closing the console on Windows), then stops
// gracefully: open files are saved and compacted, and everyone is told the server is
// restarting (their apps reconnect once it runs again).
let (stop, stopped) = std::sync::mpsc::channel();
if let Err(e) = ctrlc::set_handler(move || {
let _ = stop.send(());
}) {
// Still safe to kill: what was confirmed is on disk already.
log::warn!("can't watch for stop signals ({e}); stopping won't save open files first");
loop {
std::thread::park();
}
}
let _ = stopped.recv();
log::info!("stopping: saving open files and telling everyone the server is restarting");
running.stop();
log::info!("stopped");
ExitCode::SUCCESS
}

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();
}

View File

@ -0,0 +1,495 @@
//! A server open to the public: anyone publishes without the key and manages the file with its
//! owner token; the caps per address and for everyone, rate limits, idle expiry, blocking,
//! reports, the uptime check, `X-Forwarded-For` only from the trusted proxy, and old clients.
use std::io::{Read, Write};
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ServerMsg};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Document, NodeKind};
use serde_json::{Value, json};
use tungstenite::client::IntoClientRequest;
use tungstenite::{Bytes, Message};
use crate::abuse::{Abuse, AbuseConfig, Action, MAX_ADDRESSES};
use crate::cli::{self, Cli, Command, Sort};
use crate::config::Config;
use crate::store::{KEY_ONLY, Store};
use crate::test_util::{Raw, TempDir, config, join, key, live, pump, session};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn cli(cfg: &Config, command: Command) -> Result<String, String> {
cli::run(&Cli { config: cfg.clone(), command })
}
#[test]
fn anyone_publishes_and_the_owner_token_manages_the_file() {
let dir = TempDir::new("public");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let url = format!("ws://{addr}/");
let mut a = session("Ada");
// The server says it is open, who runs it and its terms before anything is uploaded.
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
assert_eq!(info["open"], true);
assert_eq!(info["operator"], "abuse@example.com");
assert_eq!(info["terms"], "https://example.com/terms");
assert_eq!(info["idleDays"], 30.0);
// Published without a key: links, an owner token shown once, and an expiry date.
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live().hosting.is_some());
let p = a.live().published.clone().unwrap();
let owner = p.owner.clone().expect("an owner token");
assert_eq!(owner.len(), 22, "128 bits");
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
let summary = a.live_summary();
assert_eq!(summary["published"]["owner"], owner.as_str());
assert_eq!(summary["hosting"]["operator"], "abuse@example.com");
// The owner token is kept only as a hash.
let meta = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("meta.json")).unwrap();
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
// Someone joining hears about the hosting too (with the file's expiry).
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live().hosting.as_ref().is_some_and(|h| h.expires.is_some()));
// The owner lists, makes, revokes links and renames the file.
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
p2.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("collab.manageFile", &p2)
};
let links = manage(&mut a, json!({"action": "links"})).unwrap();
assert_eq!(links["links"].as_array().unwrap().len(), 2);
let made = manage(&mut a, json!({"action": "createLink", "access": "view", "label": "Review"})).unwrap();
assert!(made["link"].as_str().unwrap().starts_with("figcraft://join?host="), "{made}");
let view_id = links["links"].as_array().unwrap().iter().find(|l| l["access"] == "view").unwrap()["id"].as_str().unwrap().to_string();
assert_eq!(manage(&mut a, json!({"action": "revoke", "link": view_id})).unwrap()["done"], true);
manage(&mut a, json!({"action": "rename", "name": "Poster v2"})).unwrap();
assert!(cli(&cfg, Command::Files { sort: Sort::Size }).unwrap().contains("Poster v2"));
// A wrong token, or another file's, manages nothing.
let e = a.execute("collab.manageFile", &json!({"server": url, "file": p.file, "owner": "AAAAAAAAAAAAAAAAAAAAAA", "action": "links"}));
assert!(e.unwrap_err().to_string().contains("doesn't manage"));
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner: owner.clone() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
raw.send(&ClientMsg::Admin(Admin::Remove { file: "0000000000000000".into() }));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
raw.send(&ClientMsg::Admin(Admin::Listing));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
// Removing it disconnects everyone in it (whoever removed it just leaves: no failure there).
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
pump(&mut [&mut a, &mut b], "everyone told", |s| s[0].live_summary()["status"] == "off" && s[1].live_summary()["status"] == "failed");
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
assert!(!dir.path().join("files").join(&p.file).exists());
server.stop();
}
#[test]
fn a_private_server_still_needs_its_key() {
let dir = TempDir::new("private");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
assert_eq!(info["open"], false);
ex(&mut a, "collab.publish", json!({"server": url}));
pump(&mut [&mut a], "the refusal", |s| s[0].live_summary()["status"] == "failed");
assert!(a.message.as_deref().unwrap_or_default().contains("only from people who have its key"), "{:?}", a.message);
assert!(!dir.path().join("files").read_dir().unwrap().any(|_| true), "nothing kept");
// The operator's key publishes as before (no owner token, no expiry).
let p = crate::test_util::publish(&mut a, dir.path(), server.addr());
assert!(p.owner.is_none() && p.expires.is_none());
server.stop();
}
#[test]
fn caps_per_address_and_for_everyone() {
let dir = TempDir::new("caps");
let mut c = open(dir.path());
c.max_files_per_ip = 2;
c.publishes_per_hour = 100;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (a, b) = (ip("203.0.113.1"), ip("203.0.113.2"));
store.publish_open(a, "One", &snapshot("One")).unwrap();
store.publish_open(a, "Two", &snapshot("Two")).unwrap();
let e = store.publish_open(a, "Three", &snapshot("Three")).unwrap_err();
assert!(e.contains("keeps 2 files"), "{e}");
store.publish_open(b, "Other", &snapshot("Other")).unwrap();
drop(store);
// Bytes per address, and for everyone (a byte: nothing fits).
let mut tight = c.store();
tight.hosting.max_bytes_per_ip = 1;
let mut store = Store::open(dir.path(), tight.clone()).unwrap();
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
assert!(e.contains("MiB it may keep"), "{e}");
drop(store);
tight.hosting.max_bytes_per_ip = u64::MAX;
tight.hosting.max_total_bytes = 1;
let mut store = Store::open(dir.path(), tight).unwrap();
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
assert!(e.contains("server is full"), "{e}");
drop(store);
// Publishing too often.
let mut c3 = open(dir.path());
c3.publishes_per_hour = 1;
let mut store = Store::open(dir.path(), c3.store()).unwrap();
store.publish_open(ip("198.51.100.7"), "Once", &snapshot("Once")).unwrap();
let e = store.publish_open(ip("198.51.100.7"), "Twice", &snapshot("Twice")).unwrap_err();
assert!(e.contains("too many files published"), "{e}");
// Closed to the public: nobody publishes without the key.
drop(store);
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
}
#[test]
fn idle_files_published_without_the_key_expire() {
let dir = TempDir::new("expiry");
let mut c = open(dir.path());
c.idle_days = 2.0;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
let (fresh, ..) = store.publish_open(ip("203.0.113.9"), "Fresh", &snapshot("Fresh")).unwrap();
let (keyed, ..) = store.publish("Operator's", &snapshot("Operator's")).unwrap();
store.age(&public.id, 2 * 86_400 + 1);
store.age(&keyed.id, 365 * 86_400);
store.age(&fresh.id, 86_400);
assert_eq!(store.sweep(), vec![public.id.clone()]);
let ids: Vec<String> = store.listing(&|_| None).into_iter().map(|r| r.info.id).collect();
assert!(!ids.contains(&public.id) && ids.contains(&fresh.id) && ids.contains(&keyed.id));
assert!(!dir.path().join("files").join(&public.id).exists());
// Opening it counts as using it: idle time starts over.
let mut server = figcraft_collab_net::hub::Backend::open(&mut store, &fresh.id).unwrap();
store.age(&fresh.id, 0);
assert!(store.listing(&|_| None).iter().any(|r| r.info.id == fresh.id && r.expires.unwrap() > crate::store::now() + 86_400));
// An open file never expires under someone.
store.age(&fresh.id, 10 * 86_400);
assert!(store.sweep().is_empty());
figcraft_collab_net::hub::Backend::idle(&mut store, &fresh.id, &mut server);
assert!(store.sweep().is_empty(), "everyone just left: the idle time starts now");
// A startup finds what expired while the server was down.
store.age(&fresh.id, 3 * 86_400);
drop(store);
let store = Store::open(dir.path(), c.store()).unwrap();
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
}
#[test]
fn rate_limits_and_blocks_refuse_with_a_reason() {
let dir = TempDir::new("limits");
let mut c = open(dir.path());
c.connects_per_minute = 3;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let ask = || {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting {
protocol: PROTOCOL,
client_version: String::new(),
file: String::new(),
owner: String::new(),
}));
raw.next()
};
for _ in 0..3 {
assert!(matches!(ask(), ServerMsg::Hosting(_)));
}
assert!(matches!(ask(), ServerMsg::Refused { reason } if reason.contains("too many connections from your address")));
server.stop();
// Messages per address, and blocking.
let mut c = open(dir.path());
c.messages_per_second = 5;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
let refused = (0..200).find_map(|_| {
raw.send(&ClientMsg::Ping);
match raw.next() {
ServerMsg::Refused { reason } => Some(reason),
_ => None,
}
});
assert!(refused.is_some_and(|r| r.contains("too many messages from your address")));
let mut held = Raw::connect(addr);
held.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(held.next(), ServerMsg::Hosting(_)));
assert!(cli(&c, Command::Block { ip: "127.0.0.1".into() }).unwrap().contains("Blocked"));
assert!(matches!(held.next(), ServerMsg::Refused { reason } if reason.contains("no longer accepts")));
let mut again = Raw::connect(addr);
again.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
assert!(matches!(again.next(), ServerMsg::Refused { .. }));
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
server.stop();
// Kept across a restart (the commands work on the directory itself then).
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
assert!(cli(&c, Command::Unblock { ip: "127.0.0.1".into() }).unwrap().contains("Unblocked"));
assert!(cli(&c, Command::Block { ip: "2001:db8:1:2:3:4:5:6".into() }).unwrap().contains("Blocked"));
assert_eq!(cli(&c, Command::Blocked).unwrap(), "2001:db8:1:2::\n", "an IPv6 address blocks its /64");
assert!(cli(&c, Command::Block { ip: "not an address".into() }).is_err());
}
#[test]
fn participants_report_files_to_the_operator() {
let dir = TempDir::new("reports");
let cfg = open(dir.path());
let mut server = crate::start(&cfg).unwrap();
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
let mut b = join(&p.view, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live().hosting.is_some());
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));
let reports = cli(&cfg, Command::Reports).unwrap();
assert!(reports.contains(&p.file) && reports.contains("Spam with a link") && reports.contains("Flyer"), "{reports}");
assert!(cli(&cfg, Command::Files { sort: Sort::Idle }).unwrap().lines().any(|l| l.contains(&p.file) && l.contains("127.0.0.1")));
server.stop();
// Kept on disk.
assert!(cli(&cfg, Command::Reports).unwrap().contains("Spam with a link"));
}
/// Connect with `X-Forwarded-For: forwarded` and open a hosting connection: what the server said.
pub(crate) fn via_proxy(addr: SocketAddr, forwarded: &str) -> (tungstenite::WebSocket<TcpStream>, ServerMsg) {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut req = format!("ws://{addr}/").into_client_request().unwrap();
req.headers_mut().insert("X-Forwarded-For", forwarded.parse().unwrap());
let (mut ws, _) = tungstenite::client::client(req, s).unwrap();
let open = ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() });
ws.send(Message::Binary(Bytes::from(message::encode(&open).unwrap()))).unwrap();
let reply = loop {
if let Message::Binary(b) = ws.read().unwrap() {
break message::decode(&b, usize::MAX).unwrap();
}
};
(ws, reply)
}
#[test]
fn forwarded_addresses_count_only_from_the_trusted_proxy() {
let dir = TempDir::new("proxy");
let mut c = open(dir.path());
c.max_connections_per_ip = 1;
c.trusted_proxy = Some("127.0.0.1".into());
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
// Behind the proxy each client is its own address: the last one the proxy added.
let (_one, r) = via_proxy(addr, "203.0.113.1");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_two, r) = via_proxy(addr, "10.0.0.1, 203.0.113.2");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_three, r) = via_proxy(addr, "203.0.113.2, 203.0.113.1");
assert!(matches!(r, ServerMsg::Refused { reason } if reason.contains("too many connections")));
// Blocking the forwarded address blocks that client only.
cli(&c, Command::Block { ip: "203.0.113.9".into() }).unwrap();
assert!(matches!(via_proxy(addr, "203.0.113.9").1, ServerMsg::Refused { .. }));
assert!(matches!(via_proxy(addr, "203.0.113.10").1, ServerMsg::Hosting(_)));
server.stop();
// Without a trusted proxy the header is anyone's to forge: the connection's own address counts.
c.trusted_proxy = None;
let mut server = crate::start(&c).unwrap();
let addr = server.addr();
let (_one, r) = via_proxy(addr, "203.0.113.1");
assert!(matches!(r, ServerMsg::Hosting(_)));
let (_two, r) = via_proxy(addr, "203.0.113.2");
assert!(matches!(r, ServerMsg::Refused { .. }));
server.stop();
}
/// Send `request` on a raw socket; what came back.
fn http(addr: SocketAddr, request: &str) -> String {
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
s.write_all(request.as_bytes()).unwrap();
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
}
#[test]
fn uptime_checks_share_the_port() {
let dir = TempDir::new("healthz");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let ok = http(addr, "GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n");
assert!(ok.starts_with("HTTP/1.1 200 OK") && ok.ends_with("ok\n"), "{ok}");
// Under a path (behind a proxy's prefix), and with a query.
assert!(http(addr, "GET /figcraft/healthz?x=1 HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 200"));
// Anything else is a WebSocket handshake or nothing.
assert!(!http(addr, "GET /healthzz HTTP/1.1\r\n\r\n").contains("200 OK"));
assert!(!http(addr, "POST /healthz HTTP/1.1\r\n\r\n").contains("200 OK"));
// Sessions still work beside it.
let mut a = session("Ada");
let p = crate::test_util::publish(&mut a, dir.path(), addr);
assert!(!p.edit.is_empty());
server.stop();
}
#[test]
fn older_clients_get_clear_answers() {
let dir = TempDir::new("old");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
// An older app tries to publish with a wrong key: it is told the server is open.
let e =
manage::request(&Endpoint::plain(addr.to_string()), "wrong", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("open to the public"), "{e}");
// A hello that doesn't announce `hosting` is never sent what it can't read: after its
// welcome, the answer to its ping comes before anything about hosting would.
let mut a = session("Ada");
let p = crate::test_util::publish(&mut a, dir.path(), addr);
let edit = Invite::parse(&p.edit).unwrap();
let mut raw = Raw::connect(addr);
let mut hello = figcraft_collab::Client::hello(&edit.file, &edit.token, "Old", "");
if let ClientMsg::Hello(h) = &mut hello {
h.client_version = "0.3.0 signals".into();
}
raw.send(&hello);
raw.send(&ClientMsg::Ping);
let seen: Vec<ServerMsg> = std::iter::from_fn(|| Some(raw.next())).take_while(|m| *m != ServerMsg::Pong).collect();
assert!(seen.iter().any(|m| matches!(m, ServerMsg::Welcome { .. })));
assert!(!seen.iter().any(|m| matches!(m, ServerMsg::Hosting(_))), "{seen:?}");
// The operator's key still manages everything.
let ok =
manage::request(&Endpoint::plain(addr.to_string()), &key(dir.path()), vec![Admin::Listing], &NetLimits::default(), Duration::from_secs(5))
.unwrap();
assert!(matches!(&ok[..], [AdminReply::Listing(rows)] if rows.len() == 1));
server.stop();
}
#[test]
fn the_address_table_stays_bounded() {
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
for i in 0..(MAX_ADDRESSES as u32 * 3) {
assert!(abuse.allow(IpAddr::from(i.to_be_bytes()), Action::Connect, t + Duration::from_micros(u64::from(i))));
}
assert!(abuse.len() <= MAX_ADDRESSES);
// A remembered address is limited; a private server limits only reports.
let me = ip("192.0.2.1");
assert!(abuse.allow(me, Action::Connect, t) && !abuse.allow(me, Action::Connect, t));
let mut closed = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
assert!((0..100).all(|_| closed.allow(me, Action::Connect, t)));
assert!((0..10).all(|_| closed.allow(me, Action::Report, t)) && !closed.allow(me, Action::Report, t));
}
#[test]
fn open_mode_has_safe_defaults_and_warns_the_operator() {
let private = Config::default();
assert_eq!(private.file_mb(), 64, "a private server behaves as before");
assert!(private.warnings().is_empty());
assert_eq!(private.net().max_per_address, 0);
let open = Config { open_publish: true, public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
assert_eq!(open.file_mb(), 16, "strangers' files stay small");
assert_eq!(open.store().hosting.idle_secs, 30 * 86_400, "idle files expire");
assert_eq!(open.net().max_per_address, 32);
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
assert_eq!(open.net().max_client_message, 17 << 20);
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
let w = open.warnings().join("\n");
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
assert!(set.warnings().is_empty(), "{:?}", set.warnings());
assert_eq!(set.file_mb(), 8);
assert!(Config { trusted_proxy: Some("localhost".into()), ..set.clone() }.check().is_err());
assert!(Config { terms_url: "javascript:alert(1)".into(), ..set.clone() }.check().is_err());
assert!(Config { idle_days: f64::NAN, ..set }.check().is_err());
}
#[test]
fn open_connections_never_reach_what_needs_the_key() {
let dir = TempDir::new("keyonly");
let mut server = crate::start(&open(dir.path())).unwrap();
let addr = server.addr();
let mut store_side = session("Ada");
let keyed = crate::test_util::publish(&mut store_side, dir.path(), addr);
let other = Invite::parse(&keyed.edit).unwrap().file;
// A file of one's own, with its owner token.
let (info, replies) = manage::hosting(
&Endpoint::plain(addr.to_string()),
"",
"",
vec![Admin::Publish { name: "Mine".into(), snapshot: snapshot("Mine") }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(info.open);
let Some(AdminReply::Hosted { file: mine, owner, .. }) = replies.into_iter().next() else { panic!("not hosted") };
let key_only = vec![
Admin::Files,
Admin::Listing,
Admin::Reports,
Admin::Blocked,
Admin::Block { ip: "203.0.113.1".into() },
Admin::Unblock { ip: "203.0.113.1".into() },
Admin::Links { file: other.clone() },
Admin::CreateLink { file: other.clone(), access: figcraft_collab::Access::Edit, label: String::new() },
Admin::Revoke { file: other.clone(), link: "x".into() },
Admin::Rename { file: other.clone(), name: "Taken".into() },
Admin::Remove { file: other.clone() },
];
// Without a token, and with the token of another file: every one is refused.
for (file, token) in [("", ""), (mine.id.as_str(), owner.as_str())] {
let (_, replies) =
manage::hosting(&Endpoint::plain(addr.to_string()), file, token, key_only.clone(), &NetLimits::default(), Duration::from_secs(5))
.unwrap();
for (req, r) in key_only.iter().zip(replies) {
assert!(matches!(r, AdminReply::Failed { .. }), "{req:?} answered {r:?}");
}
}
// The keyed file is untouched (its name, both links), nothing is blocked, and the plugin
// directory won't take a bundle.
let k = key(dir.path());
let check = manage::request(
&Endpoint::plain(addr.to_string()),
&k,
vec![Admin::Listing, Admin::Links { file: other.clone() }],
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(matches!(&check[0], AdminReply::Listing(rows) if rows.iter().any(|r| r.info.id == other && r.info.name == "Team file")));
assert!(matches!(&check[1], AdminReply::Links(links) if links.len() == 2));
assert!(std::fs::read_to_string(dir.path().join("blocked.json")).is_err());
let e = manage::plugins(
&Endpoint::plain(addr.to_string()),
"",
figcraft_collab::message::PluginReq::Publish { bundle: vec![1, 2, 3] },
&NetLimits::default(),
Duration::from_secs(5),
)
.unwrap();
assert!(matches!(e, figcraft_collab::message::PluginReply::Failed { .. }), "{e:?}");
// Open-mode answers never carry the server key.
assert!(!format!("{info:?}").contains(&k));
server.stop();
}

View File

@ -4,8 +4,7 @@
//! that skips the check), the server restarts mid-session and everyone resumes without losing a
//! confirmed edit, revoked links stop working at once, and fuzzed frames don't hurt it.
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::net::TcpStream;
use std::time::Duration;
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, ServerMsg};
@ -13,63 +12,16 @@ use figcraft_collab::sim::Rng;
use figcraft_collab::{Client, VIEW_ONLY};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::{Published, Status};
use figcraft_engine::live::Status;
use serde_json::{Value, json};
use tungstenite::{Bytes, Message, WebSocket};
use tungstenite::{Bytes, Message};
use crate::test_util::{TempDir, config, pump, same, session};
fn key(dir: &Path) -> String {
std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string()
}
use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, same, session};
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn live(s: &Session) -> bool {
s.live.status == Status::Live && s.live.syncing()
}
/// `a` publishes its open file to the server at `addr`; the links.
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()
}
fn join(link: &str, name: &str) -> Session {
let mut s = session(name);
s.execute("collab.join", &json!({"link": link})).unwrap();
s
}
/// A bare client: hello, then what it's told.
struct Raw(WebSocket<TcpStream>);
impl Raw {
fn connect(addr: SocketAddr) -> Raw {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0)
}
fn send(&mut self, m: &ClientMsg) {
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
}
fn next(&mut self) -> ServerMsg {
loop {
if let Message::Binary(b) = self.0.read().unwrap() {
return message::decode(&b, usize::MAX).unwrap();
}
}
}
}
#[test]
fn publish_then_edit_and_view_together() {
let dir = TempDir::new("share");
@ -82,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:"));
@ -91,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);
@ -112,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);
@ -138,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();
@ -154,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");
@ -208,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();
}
@ -229,7 +181,10 @@ fn revoked_links_stop_working_at_once() {
#[test]
fn fuzzed_frames_dont_hurt_the_server() {
let dir = TempDir::new("fuzz");
let mut server = crate::start(&config(dir.path())).unwrap();
// Open to the public, so publishing without the key and owner tokens are fuzzed too (with
// rates high enough that the noise gets in).
let cfg = crate::config::Config { open_publish: true, connects_per_minute: 100_000, messages_per_second: 100_000, ..config(dir.path()) };
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
@ -238,6 +193,42 @@ fn fuzzed_frames_dont_hurt_the_server() {
Client::hello(&edit.file, &edit.token, "Fuzz", ""),
ClientMsg::Manage(message::Manage { protocol: message::PROTOCOL, key: key(dir.path()), client_version: String::new() }),
ClientMsg::Admin(Admin::Links { file: "0000000000000000".into() }),
ClientMsg::OpenLibrary(message::OpenLibrary {
protocol: message::PROTOCOL,
file: edit.file.clone(),
token: edit.token.clone(),
client_version: String::new(),
}),
ClientMsg::Library(message::LibraryReq::Fetch {
have: None,
usage: Some(message::Usage { file: "Fuzz".into(), components: vec![(NodeId(1), 2)] }),
}),
ClientMsg::OpenPlugins(message::OpenPlugins { protocol: message::PROTOCOL, key: String::new(), client_version: String::new() }),
ClientMsg::Plugins(message::PluginReq::List { query: "tiny".into() }),
ClientMsg::OpenHosting(message::OpenHosting {
protocol: message::PROTOCOL,
client_version: String::new(),
file: String::new(),
owner: String::new(),
}),
ClientMsg::OpenHosting(message::OpenHosting {
protocol: message::PROTOCOL,
client_version: String::new(),
file: edit.file.clone(),
owner: "AAAAAAAAAAAAAAAAAAAAAA".into(),
}),
ClientMsg::Admin(Admin::Publish {
name: "Fuzz".into(),
snapshot: figcraft_collab::Replica::new(figcraft_doc::Document::new("F")).snapshot().unwrap(),
}),
ClientMsg::Admin(Admin::Rename { file: edit.file.clone(), name: "Fuzzed".into() }),
ClientMsg::Admin(Admin::Listing),
ClientMsg::Admin(Admin::Block { ip: "203.0.113.1".into() }),
ClientMsg::Report { reason: "fuzz".into() },
ClientMsg::Plugins(message::PluginReq::Fetch { id: "org.example.tiny".into(), version: None }),
ClientMsg::Plugins(message::PluginReq::Publish {
bundle: figcraft_plugin::bundle::Bundle { manifest: "{}".into(), wasm: b"\0asm".to_vec(), assets: Vec::new() }.encode(),
}),
ClientMsg::Ping,
]
.iter()

File diff suppressed because it is too large Load Diff

View File

@ -99,7 +99,7 @@ fn a_file_survives_a_crash_and_compacts() {
let dir = TempDir::new("crash");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, before, seq) = {
let mut store = Store::open(dir.path(), c).unwrap();
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Crash");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
@ -131,7 +131,7 @@ fn a_file_survives_a_crash_and_compacts() {
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c).unwrap();
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);

View File

@ -1,15 +1,19 @@
//! What the tests share: temporary data directories (removed when dropped), settings, and engine
//! sessions linked over loopback.
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, ServerMsg};
use figcraft_collab_net::NetLink;
use figcraft_collab_net::guest::GuestConfig;
use figcraft_engine::Session;
use figcraft_engine::live::{Published, Status};
use serde_json::json;
use tungstenite::{Bytes, Message, WebSocket};
use crate::config::Config;
@ -81,3 +85,88 @@ pub fn pump(sessions: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session
pub fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
}
pub fn key(dir: &Path) -> String {
std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string()
}
pub fn live(s: &Session) -> bool {
s.live().status == Status::Live && s.live().syncing()
}
/// `a` publishes its open file to the server at `addr`; the links.
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()
}
pub fn join(link: &str, name: &str) -> Session {
let mut s = session(name);
s.execute("collab.join", &json!({"link": link})).unwrap();
s
}
/// A bare client: hello, then what it's told.
pub struct Raw(WebSocket<TcpStream>);
impl Raw {
pub fn connect(addr: SocketAddr) -> Raw {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0)
}
pub fn send(&mut self, m: &ClientMsg) {
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
}
/// Write just the header of a masked binary frame declaring `len` payload bytes: the server
/// refuses an oversized message from its header, before the client would push the payload
/// into a socket the server has already closed (a broken pipe on Linux).
pub fn send_header(&mut self, len: u64) {
let mut h = vec![0x82u8, 0x80 | 127];
h.extend_from_slice(&len.to_be_bytes());
h.extend_from_slice(&[1, 2, 3, 4]);
self.raw_write(&h);
}
/// Write raw bytes on the socket (a hand-made, partial frame).
pub fn raw_write(&mut self, bytes: &[u8]) {
use std::io::Write as _;
self.0.get_mut().write_all(bytes).unwrap();
}
/// The server closed the connection within `limit` (anything it sends meanwhile is skipped).
pub fn closed_within(&mut self, limit: Duration) -> bool {
use std::io::Read as _;
let s = self.0.get_mut();
let end = Instant::now() + limit;
let mut buf = [0u8; 4096];
let _ = s.set_read_timeout(Some(Duration::from_millis(200)));
while Instant::now() < end {
match s.read(&mut buf) {
Ok(0) => return true,
Ok(_) => {}
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
Err(_) => return true,
}
}
false
}
/// Send without waiting to be read (`Err` once the server has hung up).
pub fn try_send(&mut self, m: &ClientMsg) -> tungstenite::Result<()> {
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap())))
}
pub fn next(&mut self) -> ServerMsg {
loop {
if let Message::Binary(b) = self.0.read().unwrap() {
return message::decode(&b, usize::MAX).unwrap();
}
}
}
}

View File

@ -25,4 +25,4 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }

View File

@ -86,4 +86,25 @@ impl Services for WebServices {
fn reduce_motion(&self) -> bool {
self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches)
}
/// The browser's language (`navigator.language`).
fn system_language(&self) -> Option<String> {
web_sys::window().and_then(|w| w.navigator().language())
}
/// The Gamepad API's controllers with the standard mapping (its button order is the layout's;
/// others are skipped). Browsers list controllers once one of their buttons is pressed.
fn gamepad_held(&mut self) -> u32 {
let Some(pads) = web_sys::window().and_then(|w| w.navigator().get_gamepads().ok()) else { return 0 };
let mut held = 0;
for pad in pads.iter().filter_map(|p| p.dyn_into::<web_sys::Gamepad>().ok()) {
if pad.mapping() != web_sys::GamepadMappingType::Standard {
continue;
}
for (i, b) in pad.buttons().iter().take(32).enumerate() {
if b.dyn_into::<web_sys::GamepadButton>().is_ok_and(|b| b.pressed()) {
held |= 1 << i;
}
}
}
held
}
}

View File

@ -14,6 +14,7 @@ workspace = true
figcraft-engine = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-format = { workspace = true }
figcraft-text = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
@ -25,9 +26,13 @@ webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
# The system's reduce-motion setting (UISettings.AnimationsEnabled).
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement"] }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
[dev-dependencies]
wat = { workspace = true }
[build-dependencies]
winresource = "0.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}");

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,77 @@ 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 (is the window visible?)"})
}
}
}
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
break;
}
writeln!(out, "{reply}")?;
}
Ok(())
}
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;

View File

@ -0,0 +1,262 @@
#![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{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
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 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();
}
}

View File

@ -0,0 +1,50 @@
//! Controllers for the prototype player's key/gamepad triggers: the buttons held, as bits at their
//! standard gamepad layout indices (`figcraft_doc::proto::GAMEPAD_BUTTONS`).
//! Windows reads Windows.Gaming.Input (Xbox-compatible controllers, through the `windows` crate the
//! app already uses). macOS, Linux and FreeBSD report none yet: the usual Rust controller library
//! links the system's libudev on Linux, which the pure-Rust builds avoid.
/// The standard layout's buttons held on any controller (bit i: index i).
#[cfg(target_os = "windows")]
pub fn held() -> u32 {
use windows::Gaming::Input::{Gamepad, GamepadButtons as B};
// The standard index of each button Windows reports; the triggers are analog (down past halfway).
const MAP: [(B, u32); 14] = [
(B::A, 0),
(B::B, 1),
(B::X, 2),
(B::Y, 3),
(B::LeftShoulder, 4),
(B::RightShoulder, 5),
(B::View, 8),
(B::Menu, 9),
(B::LeftThumbstick, 10),
(B::RightThumbstick, 11),
(B::DPadUp, 12),
(B::DPadDown, 13),
(B::DPadLeft, 14),
(B::DPadRight, 15),
];
let Ok(pads) = Gamepad::Gamepads() else { return 0 };
let mut held = 0;
for pad in pads {
let Ok(r) = pad.GetCurrentReading() else { continue };
for (b, i) in MAP {
if r.Buttons.contains(b) {
held |= 1 << i;
}
}
if r.LeftTrigger > 0.5 {
held |= 1 << 6;
}
if r.RightTrigger > 0.5 {
held |= 1 << 7;
}
}
held
}
#[cfg(not(target_os = "windows"))]
pub fn held() -> u32 {
0
}

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);
}
}

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);
}

View File

@ -11,24 +11,38 @@
mod args;
mod control_server;
mod gamepad;
mod graphics;
mod recovery;
mod services;
#[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;
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use figcraft_ui_egui::windows::with_app;
use figcraft_ui_egui::{ControlRequest, FigApp};
use serde_json::json;
/// The app state lives in [`figcraft_ui_egui::windows`] (other windows draw it too).
struct App {
fig: FigApp,
queue: Arc<Mutex<Vec<ControlRequest>>>,
ctx_slot: Arc<Mutex<Option<egui::Context>>>,
/// The window title last sent: a viewport command asks for another frame, so it's only sent
/// when the title changes (sending it every frame kept an idle window redrawing).
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
}
impl eframe::App for App {
@ -39,40 +53,44 @@ impl eframe::App for App {
*slot = Some(ctx.clone());
}
}
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
self.fig.control_queue.extend(q.drain(..));
drop(q);
let title = with_app(|fig| {
control_server::drain_queue(&self.queue, &mut fig.control_queue);
// 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);
fig.title()
});
// Window title follows the document.
let title = match self.fig.session.ds() {
Ok(ds) => format!("{}{} – FigCraft", ds.doc.name, if ds.is_dirty() { " •" } else { "" }),
Err(_) => "FigCraft".into(),
};
if title != self.title {
if let Some(title) = title
&& title != self.title
{
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
let now = ctx.input(|i| i.time);
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// Pointer and key events sent over the control channel (`ui.input`).
raw_input.events.append(&mut self.fig.control_input);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Last-resort guard: a panic in the UI frame becomes a message, not a lost document.
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.fig.ui(ui)));
if let Err(e) = res {
let msg =
e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
log::error!("UI panic: {msg}");
self.fig.toast(format!("Internal error (please report): {msg}"));
ui.ctx().request_repaint();
services::emergency_save(&self.fig.session);
// Pointer and key events sent over the control channel (`ui.input`), for the window
// about to draw.
if let Some(events) = with_app(|fig| figcraft_ui_egui::windows::take_input(fig, raw_input.viewport_id)) {
raw_input.events.extend(events);
}
}
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)));
}
fn on_exit(&mut self) {
services::save_state(&self.fig);
with_app(|fig| services::save_state(fig));
if let Some(r) = recovery::lock(&self.recovery).take() {
r.finish();
}
}
}
@ -112,19 +130,36 @@ fn main() -> eframe::Result {
log::error!("{f}: {e}");
}
}
let svc = services::Desktop::load();
// 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"));
// 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!({}));
}
let mut fig = FigApp::new(session, Box::new(svc));
fig.ui.home = false;
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
}
@ -150,6 +185,8 @@ 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() };
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { fig, queue, ctx_slot, title: String::new() }))))
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 }))))
}

View File

@ -0,0 +1,276 @@
//! Recovery copies, so a crash, a forced quit or a power cut loses at most a few seconds of work.
//!
//! While the app runs, each document with unsaved changes is copied to `Recovery/<pid>/<uid>.figcraft`
//! 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.
//!
//! 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;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use figcraft_engine::Session;
use serde_json::json;
/// Seconds between copies of a document that keeps changing.
pub const INTERVAL: f64 = 10.0;
const LOCK: &str = "lock";
/// The app's recovery, shared by its frame loop and its services (the panic path).
pub type Shared = Arc<Mutex<Option<Recovery>>>;
pub fn lock(r: &Shared) -> MutexGuard<'_, Option<Recovery>> {
r.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
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<()>>)>,
/// 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>,
}
/// A copy of one of your online files whose file is being joined again as session `session`.
pub struct Waiting {
session: u64,
copy: PathBuf,
/// The lock of the folder it is in: no other start takes the copy meanwhile.
_lock: Arc<File>,
}
/// What [`recover_all`] brought back.
#[derive(Default)]
pub struct Recovered {
/// Documents opened from their copies.
pub copies: usize,
/// Copies of online files waiting for their file ([`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).
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, waiting: Vec::new() })
}
/// Keep earlier runs' copies of online files until their outcome is known.
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> {
self.settle(s);
// A join on its way settles within its timeouts: look again soon.
let settling = (!self.waiting.is_empty()).then_some(0.5);
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
if now < self.next {
return pending.then_some(self.next - now).or(settling);
}
self.write(s, false);
self.next = now + INTERVAL;
settling
}
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
fn settle(&mut self, s: &Session) {
if self.waiting.iter().all(|w| s.live.recovering(w.session)) {
return;
}
self.write(s, true);
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) =
std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session));
self.waiting = waiting;
for w in done {
let _ = std::fs::remove_file(&w.copy);
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
// The folder goes with its last copy (while another waits there its lock is held).
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1);
drop(w);
if let Some(dir) = dir {
let _ = std::fs::remove_file(dir.join(LOCK));
let _ = std::fs::remove_dir(dir);
}
}
}
/// 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));
let note = path.with_extension(ONLINE);
if !needs_copy(d) {
if self.written.remove(&d.uid).is_some() {
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 {
continue;
}
let doc = d.doc.clone();
// 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) {
return;
}
let _ = match online {
Some(text) => std::fs::write(&note, text),
None => std::fs::remove_file(&note),
};
};
if wait {
job();
entry.1 = None;
} else {
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
}
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, _| {
let keep = open.contains(uid);
if !keep {
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. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
let Self { dir, _lock, written, .. } = self;
// 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) {
let _ = h.join();
}
drop(_lock);
let _ = std::fs::remove_dir_all(dir);
}
}
/// 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 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()
}
/// Write `doc` 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) -> bool {
let Ok(bytes) = figcraft_format::save_bytes(doc, None) 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 {
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, then remove them.
/// A copy that can't be read stays where it is. A copy of one of your online files is kept
/// instead while its file is joined again ([`Recovered::waiting`]).
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 { session, copy: file.to_path_buf(), _lock: lock.clone() });
} else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
out.copies += 1;
let _ = std::fs::remove_file(file);
}
};
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
open(&path, None, s);
continue;
}
// Still running (another window of the app), or not ours to take.
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
if lock.try_lock().is_err() {
continue;
}
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, Some(&lock), s);
}
}
// 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 waits for its file (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);
}
}
out
}

View File

@ -0,0 +1,355 @@
//! 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 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_none(), "the folder is gone");
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);
}

View File

@ -0,0 +1,64 @@
//! Recovery copies: a run that ends without closing leaves copies of its unsaved documents that the
//! next start opens again (a running app's are left alone); a normal quit leaves nothing.
use figcraft_engine::Session;
use serde_json::json;
use crate::recovery::{Recovery, recover};
fn root(name: &str) -> std::path::PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
r
}
fn edited() -> Session {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s
}
#[test]
fn a_crashed_runs_unsaved_documents_come_back() {
let root = root("crash");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
// While it runs, another start leaves its copies alone.
let mut other = Session::empty();
assert_eq!(recover(&root, &mut other), 0);
// It ends without closing (the lock goes with the process).
drop(r);
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1);
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");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
let root = root("quit");
let mut s = edited();
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
let copies =
|d: &std::path::Path| std::fs::read_dir(d).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count();
assert_eq!(copies(&dir), 1);
// Saved: the copy goes.
s.ds_mut().unwrap().mark_saved();
r.write(&s, true);
assert_eq!(copies(&dir), 0);
// Changes pending while the interval runs: it asks to be woken for them.
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
assert!(r.tick(&s, 0.0).is_none());
assert!(r.tick(&s, 1.0).is_none(), "copied at 0 s: nothing new");
s.execute("shape.rectangle", &json!({"x": 9, "y": 0, "width": 4, "height": 4})).unwrap();
assert!(r.tick(&s, 1.0).is_some_and(|w| w > 0.0));
r.finish();
assert!(!dir.exists(), "a normal quit removes the copies");
let _ = std::fs::remove_dir_all(&root);
}

View File

@ -1,6 +1,6 @@
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@ -10,12 +10,18 @@ use serde_json::{Value, json};
pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
/// Where the preferences live ([`config_dir`]).
dir: Option<PathBuf>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
/// The system's interface language (read at startup).
language: Option<String>,
/// Recovery copies of unsaved files (written at once after a panic).
recovery: crate::recovery::Shared,
}
fn config_dir() -> Option<PathBuf> {
pub fn config_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"));
#[cfg(target_os = "windows")]
@ -26,11 +32,14 @@ fn config_dir() -> Option<PathBuf> {
}
impl Desktop {
pub fn load() -> Self {
let state: Value = config_dir()
.and_then(|d| std::fs::read_to_string(d.join("state.json")).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(json!({}));
pub fn load(recovery: crate::recovery::Shared) -> Self {
Self::load_from(config_dir(), recovery)
}
/// With the preferences kept in `dir`.
pub fn load_from(dir: Option<PathBuf>, recovery: crate::recovery::Shared) -> Self {
let state: Value =
dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
// Recent files lived next to the preferences before they moved into them.
if p.recent_files.is_empty()
@ -44,7 +53,43 @@ impl Desktop {
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion }
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
}
}
/// 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.
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(","))
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
.ok()
}
// `defaults` prints a list: ( "es-ES", en ).
#[cfg(target_os = "macos")]
{
let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().ok()?;
let list: Vec<String> = String::from_utf8_lossy(&out.stdout)
.split(['(', ',', ')'])
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect();
Some(list.join(","))
}
// gettext's `LANGUAGE` priority list ("fr:de"), then the locale ("fr_FR.UTF-8").
#[cfg(all(unix, not(target_os = "macos")))]
{
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty() && v != "C" && v != "POSIX");
let locale = ["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().find_map(var);
let list: Vec<String> = var("LANGUAGE").map(|l| l.replace(':', ",")).into_iter().chain(locale).collect();
(!list.is_empty()).then(|| list.join(","))
}
#[cfg(not(any(windows, unix)))]
{
None
}
}
@ -86,33 +131,39 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
pub fn save_state(app: &FigApp) {
let Some(dir) = config_dir() else { return };
let _ = std::fs::create_dir_all(&dir);
let v = json!({"prefs": app.session.prefs});
if let Some(dir) = config_dir() {
write_prefs(&dir, &app.session.prefs);
}
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
let tmp = dir.join("state.json.tmp");
if std::fs::write(&tmp, v.to_string()).is_ok() {
let _ = std::fs::rename(&tmp, dir.join("state.json"));
let written = std::fs::create_dir_all(dir)
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
if let Err(e) = written {
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
}
}
/// After an unexpected panic: write every dirty document to the recovery folder.
pub fn emergency_save(s: &Session) {
let Some(dir) = config_dir().map(|d| d.join("Recovery")) else { return };
let _ = std::fs::create_dir_all(&dir);
for d in &s.docs {
if d.is_dirty()
&& let Ok(bytes) = figcraft_format_save(&d.doc)
{
let name = d.doc.name.replace(['/', '\\', ':'], "_");
let _ = std::fs::write(dir.join(format!("{name}-{}.figcraft", d.uid)), bytes);
}
}
}
fn figcraft_format_save(doc: &figcraft_engine::doc::Document) -> Result<Vec<u8>, String> {
let v = serde_json::to_vec(doc).map_err(|e| e.to_string())?;
Ok(v)
/// 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)
}
impl Services for Desktop {
@ -150,14 +201,34 @@ impl Services for Desktop {
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
fn save_prefs(&mut self, prefs: &Prefs) {
if let Some(dir) = &self.dir {
write_prefs(dir, prefs);
}
}
fn emergency_save(&mut self, session: &Session) {
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
r.write(session, true);
}
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
fn system_language(&self) -> Option<String> {
self.language.clone()
}
}
/// The window icon from the bundled app icon (if present).
pub fn window_icon() -> Option<egui::IconData> {
// macOS wants Apple's padded grid; the other platforms the rounded tile edge to edge.
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");
#[cfg(not(target_os = "macos"))]
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png");
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })

View File

@ -0,0 +1,84 @@
//! The preferences are kept as plugins change, not only when the app quits: a plugin imported
//! (then removed) is still there (gone) when the app is killed and starts again.
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use figcraft_ui_egui::FigApp;
use serde_json::json;
use crate::services::Desktop;
const ID: &str = "org.example.tiny";
fn root(name: &str) -> std::path::PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-prefs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
std::fs::create_dir_all(&r).unwrap();
r
}
/// A plugin folder (manifest and module) in `root`.
fn plugin(root: &std::path::Path) -> std::path::PathBuf {
let dir = root.join("tiny");
std::fs::create_dir_all(&dir).unwrap();
let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}});
std::fs::write(dir.join("figcraft-plugin.json"), manifest.to_string()).unwrap();
let wasm =
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
std::fs::write(dir.join("tiny.wasm"), wasm).unwrap();
dir
}
/// The app as it starts with its preferences in `dir`.
fn start(dir: &std::path::Path) -> FigApp {
let svc = Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)));
let mut s = Session::new();
if let Some(prefs) = svc.prefs.clone() {
s.prefs = prefs;
}
FigApp::new(s, Box::new(svc))
}
/// Draw two frames (the first only sets the fonts up).
fn frames(fig: &mut FigApp, ctx: &egui::Context) {
for _ in 0..2 {
ctx.run_ui(egui::RawInput::default(), |ui| fig.ui(ui)).textures_delta.clear();
}
}
#[test]
fn plugins_are_kept_when_the_app_is_killed() {
let dir = root("killed");
let folder = plugin(&dir);
let ctx = egui::Context::default();
let mut fig = start(&dir);
assert!(fig.run("plugin.import", json!({"path": folder.to_string_lossy()})).is_some());
frames(&mut fig, &ctx);
// Killed: no quit, so nothing saves on the way out.
drop(fig);
let mut fig = start(&dir);
assert_eq!(fig.session.prefs.plugins.iter().map(|p| p.manifest.id.as_str()).collect::<Vec<_>>(), [ID], "the plugin is still installed");
assert!(fig.run("plugin.remove", json!({"id": ID})).is_some());
frames(&mut fig, &ctx);
drop(fig);
assert!(start(&dir).session.prefs.plugins.is_empty(), "the removal was kept too");
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn preferences_are_readable_by_their_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = root("private");
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
// An older world-readable file and a leftover temporary one from a crash.
for name in ["state.json", "state.json.tmp"] {
std::fs::write(dir.join(name), "{}").unwrap();
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
}
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -0,0 +1,12 @@
FigCraft app icon (figcraft.svg, figcraft-small.svg and every file derived from them in this
directory: figcraft-<size>.png, figcraft.ico, hicolor/).
Original artwork made for this project by the project owner: an engraving-style drawing, then
vectorised. No third-party material. Licensed like the rest of FigCraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (https://opensource.org/licenses/MIT)
at your option.
Regenerate the derived files with `cargo xtask icon`.

41
assets/app-icon/README.md Normal file
View File

@ -0,0 +1,41 @@
# FigCraft app icon
A **chameleon**: an engraving portrait in profile with a curled tail, in the Crafting Apps'
owl-template framing (full-bleed field, head and body filling the tile, body running off the edges).
## Palette
Exactly three colours:
| Colour | Hex | Used for |
|---|---|---|
| Ink | `#0b0b0c` | line work and contours |
| Paper | `#efe9dc` | the figure |
| FigCraft green (app colour) | `#02da96` | the full-bleed field |
## Geometry
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
renders pad it onto Apple's 824/1024 icon grid; Windows and Linux use the tile edge to edge, with
its rounded corners, like the other Crafting Apps.
## Provenance
The owner's original engraving-style drawing (1500 px, keyed to the palette), supplied for
FigCraft and vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source
image is not kept in this repo. License: see `LICENSE.txt`.
## Files
- `figcraft.svg`: canonical master (traced at 1500 px, the source's size).
- `figcraft-small.svg`: lighter trace (traced at 1024 px); also the hicolor scalable icon.
- `figcraft-<size>.png` (16–1024): renders on the macOS grid; `packaging/macos/package.sh` builds
the `.icns` from them.
- `figcraft.ico`: Windows icon, 16–256 px, embedded in the `.exe` by `apps/figcraft/build.rs`.
- `hicolor/<size>/apps/ai.storyteller.figcraft.png` (16–512) and `hicolor/scalable/...svg`:
Linux icon theme; the 256 px one is also the runtime window icon off macOS.
## Regenerate
Replace `figcraft.svg` (and `figcraft-small.svg`), then run `cargo xtask icon`. It needs `resvg`
(`cargo install resvg`).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 103 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 453 KiB

View File

@ -19,6 +19,12 @@ web-time = "1"
# Native transport: blocking tungstenite with a reader and a writer thread per connection.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
# `wss://`: TLS with ring as the crypto provider and Mozilla's root certificates compiled in (no
# OS certificate store, no OpenSSL). Over our own split reader/writer stream (`tls.rs`), so not
# tungstenite's single-owner TLS stream.
rustls = { version = "0.23", default-features = false, features = ["std", "ring", "tls12", "logging"] }
rustls-pki-types = { version = "1", features = ["std"] }
webpki-roots = "1"
getrandom = "0.4"
base64 = { workspace = true }

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());
}

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();
}

View File

@ -13,10 +13,10 @@ use std::net::TcpStream;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel};
use std::thread::JoinHandle;
use std::time::Instant;
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Presence, ServerMsg};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, Published, Status};
use tungstenite::protocol::frame::coding::CloseCode;
@ -26,19 +26,21 @@ 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, config, dial, shutdown};
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// What [`Guest::publish`] uploads, and where.
pub struct Publish {
pub server: Endpoint,
/// The server's key.
pub key: String,
/// The server's key (`None`: publish as anyone may, on a server open to the public).
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
pub share: bool,
}
/// A joined (or joining) session. Dropping it leaves.
@ -60,10 +62,27 @@ enum Cmd {
impl Guest {
/// Start joining (returns at once; progress arrives in `inbox`).
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
pub fn start(mut cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
let (tx, rx) = channel();
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || supervise(&cfg, &inbox, &rx, &me))?;
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || {
// A join code: the server says which file it opens, then the join goes on as with a link.
if let Some(code) = cfg.code.clone() {
inbox.push(Event::Status(Status::Connecting));
let asked =
manage::resolve(&cfg.invite.server, &code, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
Some(why) => Outcome::Refused(why.to_string()),
None => Outcome::Retry(e),
});
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
inbox.push(e);
}
return;
}
}
supervise(&cfg, &inbox, &rx, &me);
})?;
Ok(Guest { cmd: tx, thread: Some(thread) })
}
@ -75,11 +94,25 @@ impl Guest {
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-publish".into()).spawn(move || {
inbox.push(Event::Status(Status::Connecting));
let (cfg, published) = match upload(&job, cfg) {
let (cfg, published, hosting) = match upload(&job, cfg) {
Ok(done) => done,
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
};
// Before anyone hears the file is there, so what others join with shows with it. The
// file's first links stay its owner's (this app joins with one): others get their own,
// which stopping takes away again.
let shared = job.share.then(|| {
let owner = published.owner.as_deref().unwrap_or_default();
let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout);
made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))
});
if let Some(h) = hosting {
inbox.push(Event::Hosting(h));
}
inbox.push(Event::Published(published));
if let Some(shared) = shared {
inbox.push(Event::Shared(shared));
}
// Left while uploading: the file stays on the server, nobody joins it. Anything else
// that came meanwhile (presence) waits for the session.
let mut waiting = Vec::new();
@ -138,26 +171,48 @@ struct Conn {
sock: TcpStream,
}
/// Upload the document; the join settings for the new file and its links.
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
let snapshot = Replica::new((*job.doc).clone()).snapshot().map_err(|e| e.to_string())?;
let replies =
manage::request(&job.server, &job.key, vec![Admin::Publish { name: job.name.clone(), snapshot }], &cfg.limits, cfg.connect_timeout)?;
let (file, edit, view) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
/// Keep `doc` on `server` as a new file named `name`, with the server's `key` (or, without one, as
/// anyone may on a server open to the public): its edit link, where it is with its links (and its
/// owner token without a key), and what the server says about its hosting (without a key).
pub(crate) fn upload_file(
server: &Endpoint,
key: Option<&str>,
name: &str,
doc: &Document,
limits: &NetLimits,
timeout: Duration,
) -> Result<(Invite, Published, Option<HostInfo>), String> {
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
let publish = vec![Admin::Publish { name: name.to_string(), snapshot }];
let (replies, hosting) = match key {
Some(key) => (manage::request(server, key, publish, limits, timeout)?, None),
None => {
let (info, replies) = manage::hosting(server, "", "", publish, limits, timeout)?;
(replies, Some(info))
}
};
let (file, edit, view, owner, expires) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
Some(AdminReply::Failed { reason }) => return Err(reason),
_ => return Err("the server didn't say where the file is".into()),
};
let link = |token: String| Invite { server: job.server.clone(), file: file.id.clone(), token };
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
let (edit, view) = (link(edit.token), link(view.token));
let published = Published { server: job.server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
let published =
Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link(), owner, expires };
Ok((edit, published, hosting))
}
/// Upload the document; the join settings for the new file, its links and the server's hosting.
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published, Option<HostInfo>), String> {
let (edit, published, hosting) = upload_file(&job.server, job.key.as_deref(), &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
cfg.invite = edit;
Ok((cfg, published))
Ok((cfg, published, hosting))
}
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
let stream = dial(&cfg.invite.server.host, cfg.connect_timeout, &cfg.limits)?;
let (rd, wr, sock) = Duplex::split(stream).map_err(|e| e.to_string())?;
let (rd, wr, sock) = open(&cfg.invite.server, cfg.connect_timeout, &cfg.limits)?;
let (reader, _) = tungstenite::client::client_with_config(cfg.invite.endpoint(), rd, Some(config(cfg.limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft session: {e}", cfg.invite.server.display()))?;
let writer = WebSocket::from_raw_socket(wr, WsRole::Client, Some(config(cfg.limits.max_client_message)));
@ -166,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;

View File

@ -11,7 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, PEOPLE};
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name};
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
use crate::inbox::Inbox;
pub use crate::join::ENDED;
use crate::net::NetLimits;
@ -85,16 +85,16 @@ impl Host {
token: String::new(),
name,
color: PEOPLE[0].to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
client_version: figcraft_collab::message::client_version(),
resume: None,
};
let (me, outs) = server.join(hello).map_err(|r| io::Error::other(format!("{r:?}")))?;
// The host's window syncs through its own client too: its welcome carries the document.
for o in outs {
if let Outgoing::To(id, w @ ServerMsg::Welcome { .. }) = o
&& id == me
{
inbox.push(Event::Sync(w));
match o {
Outgoing::To(id, w @ ServerMsg::Welcome { .. }) if id == me => inbox.push(Event::Sync(w)),
Outgoing::To(id, ref m @ ServerMsg::Signal { .. }) if id == me => to_local(&inbox, me, m),
_ => {}
}
}
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;

File diff suppressed because it is too large Load Diff

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))
}
}

View File

@ -29,6 +29,9 @@ pub(crate) const CLOSE_RESTART: u16 = 1012;
#[derive(Clone, Debug)]
pub struct GuestConfig {
pub invite: Invite,
/// A join code to ask `invite.server` about first: its answer fills in the invite's file and
/// token ([`resolved`]).
pub code: Option<String>,
/// Your name (empty: the host picks one).
pub name: String,
pub limits: NetLimits,
@ -38,18 +41,23 @@ pub struct GuestConfig {
pub max_backoff: Duration,
/// Reconnect attempts before giving up.
pub attempts: u32,
/// Join as the client you were (an app that starts over after a crash): the first hello
/// carries it, and a server that still knows the id gives it back.
pub resume: Option<Resume>,
}
impl GuestConfig {
pub fn new(invite: Invite, name: String) -> Self {
Self {
invite,
code: None,
name,
limits: NetLimits::default(),
connect_timeout: Duration::from_secs(5),
backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(10),
attempts: 12,
resume: None,
}
}
@ -67,10 +75,36 @@ pub(crate) enum Outcome {
Refused(String),
/// The host ended the session: final.
Ended(String),
/// It can't go on, for this reason as it is shown: final.
Failed(String),
/// You left.
Left,
}
/// The server couldn't be asked about a join code, or said no: final, in words for the person who
/// typed it (the server's own for a code it doesn't know).
pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
Outcome::Failed(match outcome {
// A server from before join codes can't read the question.
Outcome::Refused(why) if why.contains("couldn't be read") => {
format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
}
Outcome::Refused(why) | Outcome::Failed(why) | Outcome::Ended(why) => why,
Outcome::Retry(why) => format!("Couldn't reach the server ({why})"),
Outcome::Left => return Outcome::Left,
})
}
/// The server's answer to the join code: the invite is whole now.
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into()));
}
cfg.code = None;
(cfg.invite.file, cfg.invite.token) = (file, token);
Ok(())
}
/// What a close frame from the host means.
pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
match code {
@ -97,6 +131,7 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
match outcome {
Outcome::Left => Next::Stop(None),
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
Outcome::Failed(why) => failed(why),
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
Outcome::Retry(why) => {
@ -207,8 +242,12 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
inbox.push(Event::Sync(m));
}
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
// Answers for managing connections: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) => {}
// Checked again by the engine (the host is as untrusted as any peer).
ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)),
ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)),
ServerMsg::Reported { error } => inbox.push(Event::Reported(error)),
// Answers for managing and library connections, and to a join code: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {}
}
Taken::Continue
}

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"))]
@ -36,6 +37,8 @@ pub mod manage;
#[cfg(not(target_arch = "wasm32"))]
pub mod net;
#[cfg(not(target_arch = "wasm32"))]
pub mod tls;
#[cfg(not(target_arch = "wasm32"))]
pub mod token;
#[cfg(target_arch = "wasm32")]
@ -49,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)]
@ -59,3 +64,7 @@ mod links_tests;
mod loopback_tests;
#[cfg(test)]
mod session_tests;
#[cfg(test)]
mod sessions_tests;
#[cfg(test)]
mod tls_tests;

View File

@ -1,5 +1,6 @@
//! Transport limits, shared by the native transport and the browser client.
use std::net::IpAddr;
use std::time::Duration;
/// Transport limits (the collaboration core has its own: [`figcraft_collab::Limits`]).
@ -27,6 +28,31 @@ pub struct NetLimits {
pub queue: usize,
/// How long a write may block.
pub write_timeout: Duration,
/// Most connections from one address at once (an IPv6 address counts by its /64; 0: no cap).
pub max_per_address: usize,
/// The reverse proxy in front of a host, whose `X-Forwarded-For` names the client: only
/// connections from this address are believed (the header is anyone's to forge).
pub trusted_proxy: Option<IpAddr>,
/// Bytes queued for one client before it counts as stuck (one message always fits an empty
/// queue), for one block of addresses (an IPv4 address, an IPv6 /48), and for every client
/// together (a broadcast counted once; past it the biggest consumer is dropped).
pub max_queued_bytes: usize,
pub max_queued_block: usize,
pub max_queued_total: usize,
/// Bytes being read into messages (not whole yet) for one block, and for every connection:
/// past them the biggest readers are dropped.
pub max_reading_block: usize,
pub max_reading_total: usize,
/// Bytes of messages handed to the hub and not handled yet, all connections together: past it
/// a connection is dropped as busy instead of piling more on.
pub max_in_flight: usize,
/// Largest message on a connection that only asks small things (browsing a plugin directory).
pub max_request: usize,
/// Connections only sessions (`Hello`) and the operator may take: one-shot requests leave them.
pub reserve_for_sessions: usize,
/// A one-shot request connection (hosting, a library, a plugin directory) with no traffic for
/// this long is dropped.
pub request_idle: Duration,
}
impl Default for NetLimits {
@ -43,6 +69,17 @@ impl Default for NetLimits {
keepalive: Duration::from_secs(15),
queue: 1024,
write_timeout: Duration::from_secs(10),
max_per_address: 0,
trusted_proxy: None,
max_queued_bytes: 160 << 20,
max_queued_block: usize::MAX,
max_queued_total: usize::MAX,
max_reading_block: usize::MAX,
max_reading_total: usize::MAX,
max_in_flight: usize::MAX,
max_request: 64 << 10,
reserve_for_sessions: 0,
request_idle: Duration::from_secs(45),
}
}
}

View File

@ -1,40 +1,64 @@
//! [`NetLink`]: the engine's [`LiveLink`] over the network.
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Role, SessionInfo};
use figcraft_collab::code;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_engine::live::{
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
SessionId, SessionInfo, Shared,
};
use crate::guest::{Guest, GuestConfig, Publish};
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
use crate::host::{Host, HostConfig};
use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::manage::NO_TLS;
use crate::manage;
use crate::net::NetLimits;
use crate::sessions::Sessions;
use crate::token;
/// The port a hosted session tries first.
pub const DEFAULT_PORT: u16 = 47900;
/// How long a request to a server (outside a session) waits for its connection.
const CONNECT: Duration = Duration::from_secs(5);
enum Active {
Host(Host),
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).
libraries: Inbox,
/// Background library requests are out.
fetching: Arc<AtomicBool>,
}
impl NetLink {
/// `wake` is called (from network threads) whenever something arrives for the UI.
pub fn new(wake: Waker) -> Self {
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c }
let libraries = Inbox::new(wake.clone());
Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default() }
}
/// With other transport limits.
@ -49,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
@ -83,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();
@ -120,75 +156,164 @@ 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)?;
if invite.server.secure {
return Err(NO_TLS.into());
}
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)?;
if server.secure {
return Err(NO_TLS.into());
}
// Without a key: as anyone may, when the server is open to the public (it says if not).
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
self.leave();
let inbox = Inbox::new(self.wake.clone());
let job = Publish { server, key, name: o.name, doc: o.doc };
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
let inbox = self.sessions.inbox();
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
self.started(session, Active::Guest(guest), inbox);
Ok(())
}
fn leave(&mut self) {
match self.active.take() {
Some(Active::Host(mut h)) => h.stop(),
Some(Active::Guest(mut g)) => g.leave(),
None => {}
}
self.inbox = None;
fn upload(&mut self, o: PublishOptions) -> Result<Published, String> {
upload(o, &self.limits)
}
fn presence(&mut self, p: &Presence) {
match &self.active {
fn library(&mut self, link: &str, req: LibraryReq) -> Result<LibraryReply, String> {
manage::library(link, req, &self.limits, CONNECT)
}
fn plugins(&mut self, server: &str, req: PluginReq) -> Result<PluginReply, String> {
plugins(server, req, &self.limits)
}
fn hosting(&mut self, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
hosting(server, file, owner, req, &self.limits)
}
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
share(server, file, owner, revoke, create, &self.limits)
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
let (inbox, limits) = (self.libraries.clone(), self.limits);
let (failed, failure) = (inbox.clone(), req.failure());
let spawned = std::thread::Builder::new().name("server-request".into()).spawn(move || {
let reply = match req {
NetRequest::Plugins { server, req } => NetReply::Plugins(plugins(&server, req, &limits)),
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)),
};
inbox.push(Event::Net { job, reply: Box::new(reply) });
});
if let Err(e) = spawned {
failed.push(Event::Net { job, reply: Box::new(failure(format!("can't start a thread for the request: {e}"))) });
}
true
}
fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) {
if jobs.is_empty() || self.fetching.swap(true, Ordering::SeqCst) {
return;
}
let (inbox, fetching, limits) = (self.libraries.clone(), self.fetching.clone(), self.limits);
let spawned = std::thread::Builder::new().name("library-fetch".into()).spawn(move || {
for (link, req) in jobs {
let reply = manage::library(&link, req, &limits, CONNECT);
inbox.push(Event::Library { link, reply });
}
fetching.store(false, Ordering::SeqCst);
});
if let Err(e) = spawned {
log::warn!("team libraries: can't start a thread: {e}");
self.fetching.store(false, Ordering::SeqCst);
}
}
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> {
self.inbox.as_ref().map(Inbox::drain).unwrap_or_default()
fn poll(&mut self) -> Vec<(SessionId, Event)> {
let mut out: Vec<(SessionId, Event)> = self.libraries.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
out.extend(self.sessions.poll());
out
}
}
/// Keep `o.doc` on a server as a new file (see [`LiveLink::upload`]).
fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
let (server, query) = Endpoint::parse(&o.server)?;
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
}
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
/// its owner token (see [`LiveLink::hosting`]). A new link comes back whole, in place of its token.
fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &NetLimits) -> Result<(HostInfo, Option<AdminReply>), String> {
let (endpoint, _) = Endpoint::parse(server)?;
let (info, replies) = manage::hosting(&endpoint, file, owner, req.into_iter().collect(), limits, CONNECT)?;
let reply = replies.into_iter().next().map(|r| match r {
AdminReply::Link(mut l) => {
l.token = Invite { server: endpoint.clone(), file: l.link.file.clone(), token: l.token }.link();
AdminReply::Link(l)
}
other => other,
});
Ok((info, reply))
}
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
/// the file's owner.
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
let (endpoint, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
}
/// One request to the plugin directory at `server` (its key in the address's query, if any).
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
let (server, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default();
manage::plugins(&server, key.trim(), req, limits, CONNECT)
}
impl Drop for NetLink {
fn drop(&mut self) {
self.leave();
for id in self.sessions.ids() {
self.leave(id);
}
}
}

View File

@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status};
@ -97,7 +97,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
(host, probe, invite)
}
fn start() -> (Host, Probe, Invite) {
pub(crate) fn start() -> (Host, Probe, Invite) {
start_with(fast_limits(), Limits::default())
}
@ -384,7 +384,7 @@ fn oversized_messages_end_the_connection() {
let mut raw = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id, .. }) = raw.next() else { panic!("no welcome") };
raw.send(&ClientMsg::Presence(Presence { selection: Some((0..5000).map(NodeId).collect()), ..Presence::default() }));
assert_eq!(raw.refusal().as_deref(), Some("message too large"));
assert!(raw.refusal().is_some_and(|r| r.starts_with("message too large")));
hp.wait("it left", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == client_id)));
// The host still serves others.
let (_a, mut ap) = join(&invite, "Ada");
@ -557,3 +557,57 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
assert!(!hp.left().contains(&ada));
host.stop();
}
#[test]
fn signals_reach_only_clients_that_read_them_and_are_rate_limited() {
let (mut host, mut hp, invite) = start();
let addr: SocketAddr = invite.server.host.parse().unwrap();
hp.wait("the host's own ready", |e| e.iter().any(|e| matches!(e, Event::Signal(_, s) if s.kind == Signal::READY)));
// A client from before signals: its hello doesn't announce them.
let mut old = Raw::connect(addr);
let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Old", "") else { panic!("not a hello") };
assert!(old.send(&ClientMsg::Hello(Hello { client_version: "0.3.1".into(), ..h })));
assert!(matches!(old.next(), Some(ServerMsg::Welcome { .. })));
let mut new = Raw::hello(addr, TOKEN);
let Some(ServerMsg::Welcome { client_id: me, .. }) = new.next() else { panic!("no welcome") };
assert_eq!(new.next(), Some(ServerMsg::Signal { client_id: me, signal: Signal::ready() }));
// The host chats, then moves its pointer: the old client gets the pointer only.
host.send(ClientMsg::Signal(Signal::chat("hi")));
host.presence(presence(1.0, 2.0));
let until_presence = |r: &mut Raw| {
let mut got = Vec::new();
loop {
match r.next() {
Some(ServerMsg::Presence { .. }) | None => return got,
Some(m) => got.push(m),
}
}
};
let old_got = until_presence(&mut old);
assert!(!old_got.iter().any(|m| matches!(m, ServerMsg::Signal { .. })), "{old_got:?}");
let new_got = until_presence(&mut new);
let hana = host.me().client_id;
assert!(new_got.contains(&ServerMsg::Signal { client_id: hana, signal: Signal::chat("hi") }), "{new_got:?}");
// A flood of signals: the excess is dropped (the connection stays), the host gets them cleaned.
for i in 0..200 {
assert!(new.send(&ClientMsg::Signal(Signal::chat(&format!("{i}\u{7}")))));
}
assert!(new.send(&ClientMsg::Presence(presence(3.0, 4.0))));
hp.wait("the flood's end", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == me)));
let chats: Vec<&Signal> = hp.events.iter().filter_map(|e| if let Event::Signal(id, s) = e { (*id == me).then_some(s) } else { None }).collect();
assert!(chats.len() >= 40 && chats.len() < 200, "{} of 200 got through", chats.len());
assert!(chats.iter().all(|s| s.kind == Signal::CHAT && s.text.ends_with(' ')), "cleaned on the way");
assert!(new.send(&ClientMsg::Ping));
assert_eq!(until_presence_or_pong(&mut new), Some(ServerMsg::Pong), "still connected");
host.stop();
}
/// The next message that isn't someone's presence or signal.
fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
loop {
match r.next() {
Some(ServerMsg::Presence { .. } | ServerMsg::Signal { .. }) => {}
m => return m,
}
}
}

View File

@ -1,62 +1,233 @@
//! Managing a server's files from a client: one connection that opens with the server's key
//! ([`Manage`]) and sends requests, each answered in order. The app publishes through it, and the
//! server's command line uses it to reach a running server.
//! Requests to a server outside a live session, each on a connection of its own answered in
//! order: managing its files with the server's key ([`Manage`], [`request`]; the app publishes
//! through it, and the server's command line uses it to reach a running server), and using a
//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]), and its
//! plugin directory ([`OpenPlugins`], [`plugins`]: anyone browses, the key publishes).
use std::time::Duration;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
use figcraft_collab::message::{
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Resolve, ServerMsg,
};
use figcraft_engine::live::{Shared, SharedLink};
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::Endpoint;
use crate::net::{NetLimits, config, dial};
use crate::invite::{Endpoint, Invite};
use crate::net::{Duplex, NetLimits, config, open};
/// Why the desktop app can't reach a `wss://` address (it has no TLS of its own yet).
pub const NO_TLS: &str =
"The desktop app can't open secure (wss://) connections yet: use the server's ws:// address, or join from FigCraft in the browser";
type Ws = WebSocket<Duplex>;
/// Send `requests` to the server at `server` with its `key`; the replies, in order. Fails when the
/// server can't be reached, refuses the key or closes the connection (a request it can't do is a
/// [`AdminReply::Failed`] reply, not an error). Never echoes the key.
pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetLimits, timeout: Duration) -> Result<Vec<AdminReply>, String> {
if server.secure {
return Err(NO_TLS.into());
}
let stream = dial(&server.host, timeout, limits)?;
let (mut ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
// A large upload takes the server a while to keep.
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
let admin = |m: ServerMsg| match m {
ServerMsg::Admin(r) => Some(*r),
_ => None,
};
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::Manage(manage))?;
match reply(&mut ws, limits)? {
match reply(&mut ws, limits, admin)? {
AdminReply::Ready => {}
other => return Err(format!("the server answered {other:?} instead of letting this connection manage files")),
}
let mut replies = Vec::with_capacity(requests.len());
for r in requests {
send(&mut ws, &ClientMsg::Admin(r))?;
replies.push(reply(&mut ws, limits)?);
replies.push(reply(&mut ws, limits, admin)?);
}
let _ = ws.close(None);
// Read until the server's close arrives (or the socket ends), so the close isn't lost.
while ws.read().is_ok() {}
close(ws);
Ok(replies)
}
fn send(ws: &mut WebSocket<std::net::TcpStream>, msg: &ClientMsg) -> Result<(), String> {
/// Ask the server at `server` about its hosting, with a file's owner token when `file` and `owner`
/// aren't empty; what it says, and its replies to `requests` (publishing without the key, managing
/// that file), in order. Fails when the server can't be reached or refuses (a wrong owner token, a
/// blocked address); a server older than open hosting can't read the request, and is reported as
/// taking files only with its key. Never echoes the token.
pub fn hosting(
server: &Endpoint,
file: &str,
owner: &str,
requests: Vec<Admin>,
limits: &NetLimits,
timeout: Duration,
) -> Result<(HostInfo, Vec<AdminReply>), String> {
let admin = |m: ServerMsg| match m {
ServerMsg::Admin(r) => Some(*r),
_ => None,
};
let open = OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), file: file.to_string(), owner: owner.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenHosting(open))?;
let info = match reply(&mut ws, limits, |m| match m {
ServerMsg::Hosting(i) => Some(*i),
_ => None,
}) {
Ok(i) => i,
Err(e) if e.contains("couldn't be read") => {
return Err(format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display()));
}
Err(e) => return Err(e),
};
let mut replies = Vec::with_capacity(requests.len());
for r in requests {
send(&mut ws, &ClientMsg::Admin(r))?;
replies.push(reply(&mut ws, limits, admin)?);
}
close(ws);
Ok((info, replies))
}
/// Ask the server at `server` which file join code `code` opens: the file's id and its link's
/// token. Fails with the server's own words when it knows no such code (or the address tried too
/// many: the error starts with [`REFUSED`]); a server older than join codes can't read the
/// question ("couldn't be read").
pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
let ask = Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::Resolve(ask))?;
let answer = reply(&mut ws, limits, |m| match m {
ServerMsg::Resolved { file, token } => Some((file, token)),
_ => None,
})?;
close(ws);
Ok(answer)
}
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
/// make a new edit link and a new view link and ask for their join codes: what others join with.
/// A server older than join codes makes the links without codes.
pub fn share(
server: &Endpoint,
key: Option<&str>,
file: &str,
owner: &str,
revoke: &[String],
create: bool,
limits: &NetLimits,
timeout: Duration,
) -> Result<Option<Shared>, String> {
let ask = |requests: Vec<Admin>| match key {
Some(key) => request(server, key, requests, limits, timeout),
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
};
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
let mut made = Vec::new();
for reply in ask(requests)? {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
};
// Their codes, on a connection of its own: a server from before join codes can't read the
// request and closes it (the links are shared without codes then).
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
.unwrap_or_default()
.into_iter()
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut shared = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = shared(edit);
Ok(Some(Shared { edit, view: Some(shared(view)) }))
}
/// Send one library request about the file behind share link `link`; the reply. Fails when the
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
/// [`LibraryReply::Failed`] reply). Never echoes the link.
pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duration) -> Result<LibraryReply, String> {
let lib = |m: ServerMsg| match m {
ServerMsg::Library(r) => Some(*r),
_ => None,
};
let invite = Invite::parse(link)?;
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: env!("CARGO_PKG_VERSION").to_string() };
let mut ws = connect(&invite.server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenLibrary(open))?;
match reply(&mut ws, limits, lib)? {
LibraryReply::Ready { .. } => {}
other => return Err(format!("the server answered {other:?} instead of opening the library")),
}
send(&mut ws, &ClientMsg::Library(req))?;
let r = reply(&mut ws, limits, lib)?;
close(ws);
Ok(r)
}
/// Send one request to the plugin directory of the server at `server`, with its `key` (empty: only
/// browsing); the reply. Fails when the server can't be reached, refuses the key or has no
/// directory (an older server); a request it can't do is a [`PluginReply::Failed`] reply. Never
/// echoes the key.
pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits, timeout: Duration) -> Result<PluginReply, String> {
let pick = |m: ServerMsg| match m {
ServerMsg::Plugins(r) => Some(*r),
_ => None,
};
let open = OpenPlugins { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenPlugins(open))?;
match reply(&mut ws, limits, pick) {
Ok(PluginReply::Ready { .. }) => {}
// An older server can't read the request at all.
Err(e) if e.contains("couldn't be read") => return Err(format!("{} has no plugin directory (update the server)", server.display())),
Err(e) => return Err(e),
Ok(other) => return Err(format!("the server answered {other:?} instead of opening its plugin directory")),
}
send(&mut ws, &ClientMsg::Plugins(req))?;
let r = reply(&mut ws, limits, pick)?;
close(ws);
Ok(r)
}
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
/// server a while to keep).
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
let (stream, _, _) = open(server, timeout, limits)?;
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
Ok(ws)
}
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost.
fn close(mut ws: Ws) {
let _ = ws.close(None);
while ws.read().is_ok() {}
}
fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
let bytes = message::encode(msg).map_err(|e| e.to_string())?;
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
}
/// The next answer: an admin reply, or why there is none.
fn reply(ws: &mut WebSocket<std::net::TcpStream>, limits: &NetLimits) -> Result<AdminReply, String> {
/// 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::Admin(r)) => Ok(*r),
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
Ok(ServerMsg::Pong) => continue,
Ok(_) => Err("the server sent something other than an answer".into()),
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})")),
},
Ok(Message::Close(f)) => Err(match f.map(|f| f.reason.to_string()).filter(|r| !r.is_empty()) {

View File

@ -6,8 +6,10 @@
//! machines over the same socket; every write goes through one lock and writes whole frames, so
//! frames from the two never interleave.
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
@ -15,26 +17,164 @@ pub use crate::limits::NetLimits;
use tungstenite::protocol::WebSocketConfig;
use crate::invite::Endpoint;
use crate::tls::{self, Tls};
/// tungstenite's settings for messages up to `max` bytes.
pub fn config(max: usize) -> WebSocketConfig {
WebSocketConfig::default().max_message_size(Some(max)).max_frame_size(Some(max))
}
/// One side (read or write) of a connection: reads from its own handle, writes whole buffers
/// under the connection's write lock.
/// under the connection's write lock; through the connection's TLS session when it has one.
pub struct Duplex {
read: TcpStream,
write: Arc<Mutex<TcpStream>>,
tls: Option<Tls>,
/// What the bytes read count against (a server's reader), if anything.
meter: Option<Meter>,
}
impl Duplex {
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
pub fn split(stream: TcpStream) -> io::Result<(Duplex, Duplex, TcpStream)> {
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
let write = Arc::new(Mutex::new(stream.try_clone()?));
let a = Duplex { read: stream.try_clone()?, write: write.clone() };
let b = Duplex { read: stream.try_clone()?, write };
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
Ok((a, b, stream))
}
/// Count what this handle reads against `meter`.
pub fn meter(&mut self, meter: Meter) {
self.meter = Some(meter);
}
/// What was read so far became a message (handed on): it no longer counts as being read.
pub fn read_done(&mut self) {
if let Some(m) = self.meter.as_mut() {
m.release();
}
}
}
/// Every reader that counts what it reads, with what it holds half-read and its socket: when
/// everyone's reading budget runs out, the reader holding the most is cut off.
#[derive(Clone, Default)]
pub struct Readers(Arc<Mutex<HashMap<u64, Reader>>>);
/// One registered reader: what it holds half-read, its block's count, whether it was cut off, and
/// its socket.
struct Reader {
mine: Arc<AtomicUsize>,
block: Arc<AtomicUsize>,
shed: Arc<AtomicBool>,
sock: TcpStream,
}
impl Readers {
/// Cut off the reader holding the most half-read bytes, unless that is `me` (then `false`: the
/// caller is the biggest). Its bytes are given back to the budget at once, it can't read any
/// more, and its socket is shut (where that wakes a blocked read, it ends now; elsewhere at its
/// read timeout).
fn shed_biggest(&self, me: u64, all: &AtomicUsize) -> bool {
let t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
let Some((&id, r)) = t.iter().filter(|(_, r)| !r.shed.load(Ordering::Relaxed)).max_by_key(|(_, r)| r.mine.load(Ordering::Relaxed)) else {
return false;
};
if id == me || r.mine.load(Ordering::Relaxed) == 0 {
return false;
}
log::info!("event=shed conn={id} reason=reading_bytes");
r.shed.store(true, Ordering::Relaxed);
let held = r.mine.swap(0, Ordering::Relaxed);
r.block.fetch_sub(held, Ordering::Relaxed);
all.fetch_sub(held, Ordering::Relaxed);
let _ = r.sock.shutdown(Shutdown::Both);
true
}
/// Readers registered.
pub fn len(&self) -> usize {
self.0.lock().unwrap_or_else(PoisonError::into_inner).len()
}
/// None registered.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Bytes being read into messages (not whole yet), per connection, per block of addresses and
/// for every connection together. A read that takes its block over its share fails. One that takes
/// everyone over the budget cuts off the readers holding the most until it fits (their bytes are
/// given back at once, they can't read any more and their sockets are shut) or, when this reader is
/// the biggest, fails: the biggest readers go, not the next one.
pub struct Meter {
id: u64,
mine: Arc<AtomicUsize>,
shed: Arc<AtomicBool>,
block: Arc<AtomicUsize>,
all: Arc<AtomicUsize>,
max_block: usize,
max_all: usize,
readers: Readers,
}
/// What a reader cut off for using too much of the budget is told.
const BUSY_READING: &str = "the server is busy reading from others: try again in a moment";
impl Meter {
/// Connection `id`'s meter (registered with `readers`, cut off through `sock`).
pub fn new(
id: u64,
sock: &TcpStream,
readers: &Readers,
block: Arc<AtomicUsize>,
all: Arc<AtomicUsize>,
max_block: usize,
max_all: usize,
) -> Self {
let (mine, shed) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)));
if let Ok(s) = sock.try_clone() {
let r = Reader { mine: mine.clone(), block: block.clone(), shed: shed.clone(), sock: s };
readers.0.lock().unwrap_or_else(PoisonError::into_inner).insert(id, r);
}
Self { id, mine, shed, block, all, max_block, max_all, readers: readers.clone() }
}
fn add(&mut self, n: usize) -> io::Result<()> {
if self.shed.load(Ordering::Relaxed) {
return Err(io::Error::other(BUSY_READING));
}
self.mine.fetch_add(n, Ordering::Relaxed);
let block = self.block.fetch_add(n, Ordering::Relaxed).saturating_add(n);
let mut all = self.all.fetch_add(n, Ordering::Relaxed).saturating_add(n);
if block > self.max_block {
return Err(io::Error::other(BUSY_READING));
}
// Over everyone's budget: the biggest readers are cut off until it fits, or this one goes
// when it is the biggest.
while all > self.max_all {
if !self.readers.shed_biggest(self.id, &self.all) {
return Err(io::Error::other(BUSY_READING));
}
all = self.all.load(Ordering::Relaxed);
}
Ok(())
}
fn release(&mut self) {
let mine = self.mine.swap(0, Ordering::Relaxed);
self.block.fetch_sub(mine, Ordering::Relaxed);
self.all.fetch_sub(mine, Ordering::Relaxed);
}
}
impl Drop for Meter {
fn drop(&mut self) {
self.release();
self.readers.0.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.id);
}
}
impl Duplex {
@ -47,12 +187,22 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.read.read(buf)
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}?;
if let Some(m) = self.meter.as_mut() {
m.add(n)?;
}
Ok(n)
}
}
impl Write for Duplex {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if let Some(t) = &self.tls {
return tls::write(t, &self.write, buf);
}
self.write.lock().unwrap_or_else(PoisonError::into_inner).write_all(buf)?;
Ok(buf.len())
}
@ -66,9 +216,17 @@ pub fn shutdown(s: &TcpStream) {
let _ = s.shutdown(Shutdown::Both);
}
/// Connect to `server` (through TLS for `wss://`): the reader's and the writer's handles, and the
/// socket to shut it down with.
pub fn open(server: &Endpoint, timeout: Duration, limits: &NetLimits) -> Result<(Duplex, Duplex, TcpStream), String> {
let stream = dial(&server.host, timeout, limits)?;
let tls = if server.secure { Some(tls::handshake(&stream, &server.host)?) } else { None };
Duplex::split(stream, tls).map_err(|e| e.to_string())
}
/// Connect to `host` (`name:port`), trying each of its addresses within `timeout`, with Nagle off
/// and reads and writes bounded by `limits`' hello and write timeouts.
pub fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
use std::net::ToSocketAddrs;
let addrs = host.to_socket_addrs().map_err(|e| format!("can't find {host}: {e}"))?;
let mut last = format!("can't find {host}");
@ -102,13 +260,23 @@ impl Bucket {
/// Spend one token at `now`; false when there are none left.
pub fn take(&mut self, now: Instant) -> bool {
self.take_n(now, 1.0)
}
/// Spend `n` tokens at `now`; false (spending none) when there aren't that many.
pub fn take_n(&mut self, now: Instant, n: f64) -> bool {
let dt = now.saturating_duration_since(self.at).as_secs_f64();
self.at = now;
self.tokens = (self.tokens + dt * self.rate).min(self.burst);
if self.tokens < 1.0 {
if self.tokens < n {
return false;
}
self.tokens -= 1.0;
self.tokens -= n;
true
}
/// Give back `n` tokens (up to the burst).
pub fn give(&mut self, n: f64) {
self.tokens = (self.tokens + n).min(self.burst);
}
}

View File

@ -0,0 +1,122 @@
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
//! broadcast counts once against the server's budget however many copies are queued, a block of
//! addresses can't take more than its share, and when the server's budget is used up the
//! connection holding the most is shed, not the next client that needs a little.
use std::collections::HashMap;
use std::net::{IpAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, sync_channel};
use tungstenite::Bytes;
use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
/// A line with its own queue counter, the given block counter and the server's.
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
let (tx, rx) = sync_channel(64);
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
}
fn socket() -> TcpStream {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
TcpStream::connect(l.local_addr().unwrap()).unwrap()
}
#[test]
fn a_broadcast_counts_once_against_the_server() {
let all = Arc::new(AtomicUsize::new(0));
let lines: Vec<_> = (0..4).map(|_| line(&Arc::default(), &all, usize::MAX, 10_000)).collect();
let b = Bytes::from(vec![0u8; 3_000]);
let mut held = Charges::default();
for (l, _) in &lines {
l.push(&b, &mut held, false).unwrap();
}
assert_eq!(all.load(Ordering::Relaxed), 3_000, "four copies of one message, counted once");
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 3_000), "each queue counts its copy");
drop(held);
// Sent (or dropped): the counts go back as the copies go.
for (_, rx) in &lines {
drop(rx.try_recv().unwrap());
}
assert_eq!(all.load(Ordering::Relaxed), 0);
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 0));
}
#[test]
fn one_block_gets_only_its_share() {
let (block, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let (a, _ra) = line(&block, &all, 1_000, usize::MAX);
let (b, _rb) = line(&block, &all, 1_000, usize::MAX);
let (c, _rc) = line(&Arc::default(), &all, 1_000, usize::MAX);
let msg = Bytes::from(vec![0u8; 800]);
a.push(&msg, &mut Charges::default(), false).unwrap();
assert_eq!(b.push(&msg, &mut Charges::default(), false), Err(Push::Full), "the block's second connection is over its share");
c.push(&msg, &mut Charges::default(), false).unwrap();
}
#[test]
fn the_biggest_consumer_is_shed_when_the_budget_runs_out() {
let all = Arc::new(AtomicUsize::new(0));
let ip: IpAddr = "203.0.113.1".parse().unwrap();
let mut conns = HashMap::new();
let mut receivers = Vec::new();
for id in 1..=3u64 {
let (l, rx) = line(&Arc::default(), &all, usize::MAX, 10_000);
receivers.push(rx);
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing, ip });
}
// Connection 1 doesn't read: it holds most of the budget.
let big = Bytes::from(vec![0u8; 9_000]);
assert!(deliver(&conns, 1, &big, &mut Charges::default(), &mut Vec::new()));
// Connection 2 needs a little: the hog is shed and 2 still gets its message.
let small = Bytes::from(vec![0u8; 2_000]);
let mut shed = Vec::new();
assert!(deliver(&conns, 2, &small, &mut Charges::default(), &mut shed));
assert_eq!(shed, vec![1]);
assert_eq!(conns[&2].out.queued.load(Ordering::Relaxed), 2_000);
// When the receiver is itself the biggest, it is the one to go.
let mut shed = Vec::new();
assert!(!deliver(&conns, 1, &small, &mut Charges::default(), &mut shed), "the hog asking for more is dropped");
assert!(shed.is_empty());
}
#[test]
fn a_broadcast_counts_once_per_block_and_welcomes_not_at_all() {
let (office, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
// Twenty people behind one office's address, a block share of 3 MB.
let lines: Vec<_> = (0..20).map(|_| line(&office, &all, 3_000_000, usize::MAX)).collect();
// A 2 MB paste to all of them: one copy against the block, so everyone gets it.
let paste = Bytes::from(vec![0u8; 2_000_000]);
let mut charges = Charges::default();
for (l, _) in &lines {
l.push(&paste, &mut charges, false).unwrap();
}
assert_eq!(office.load(Ordering::Relaxed), 2_000_000);
drop(charges);
for (_, rx) in &lines {
drop(rx.try_recv().unwrap());
}
assert_eq!(office.load(Ordering::Relaxed), 0);
// Twenty welcomes of a large file at once (each its own message): not counted against the block.
let welcome = Bytes::from(vec![0u8; 1_000_000]);
for (l, _) in &lines {
l.push(&welcome, &mut Charges { skip_blocks: true, ..Charges::default() }, false).unwrap();
}
assert_eq!(office.load(Ordering::Relaxed), 0);
assert_eq!(all.load(Ordering::Relaxed), 20_000_000, "they still count against the server's budget");
}
#[test]
fn blocks_nobody_uses_are_forgotten() {
let blocks = Blocks::default();
let a: IpAddr = "203.0.113.1".parse().unwrap();
let held: BlockUse = blocks.get(a);
let _b = blocks.get("203.0.113.2".parse().unwrap());
assert_eq!(blocks.len(), 2);
drop(held);
// The next connection prunes the block whose connections are all gone.
let _c = blocks.get("203.0.113.3".parse().unwrap());
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
}

View File

@ -47,10 +47,10 @@ fn host_and_join() -> (Session, Session, u32, u32) {
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
assert_eq!(r["status"], "connecting");
pump(&mut host, &mut guest, "the join", |h, g| {
g.live.status == Status::Live && h.live.peers.len() == 1 && g.live.info.as_ref().is_some_and(|i| !i.color.is_empty())
g.live().status == Status::Live && h.live().peers.len() == 1 && g.live().info.as_ref().is_some_and(|i| !i.color.is_empty())
});
let hana = host.live.info.as_ref().unwrap().me;
let ada = guest.live.info.as_ref().unwrap().me;
let hana = host.live().info.as_ref().unwrap().me;
let ada = guest.live().info.as_ref().unwrap().me;
(host, guest, hana, ada)
}
@ -62,23 +62,23 @@ fn host_join_and_see_each_other() {
assert_eq!(g["fileName"], "Untitled", "the host's file name");
assert_eq!(g["name"], "Ada");
assert_eq!(g["peers"][0]["name"], "Hana");
assert_eq!(host.live.peers[&ada].name, "Ada");
assert_eq!(host.live().peers[&ada].name, "Ada");
// The pointer: the guest's cursor shows up in the host's state.
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
h.live().presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
});
// A selection travels too.
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let id = r["id"].as_u64().unwrap();
host.execute("select.set", &json!({"ids": [id]})).unwrap();
pump(&mut host, &mut guest, "the host's selection", |_, g| {
g.live.presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
g.live().presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
});
// Leaving: the host sees it, the guest is back to no session.
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
assert_eq!(guest.live.status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live.peers.is_empty() && h.live.presence.is_empty());
assert_eq!(guest.live().status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live().peers.is_empty() && h.live().presence.is_empty());
host.execute("collab.stop", &json!({})).unwrap();
assert_eq!(host.live_summary()["status"], "off");
}
@ -87,7 +87,7 @@ fn host_join_and_see_each_other() {
fn following_a_viewport_until_you_move() {
let (mut host, mut guest, hana, ada) = host_and_join();
host.execute("collab.follow", &json!({"client": ada})).unwrap();
assert_eq!(host.live.follow, Some(ada));
assert_eq!(host.live().follow, Some(ada));
// The guest pans and zooms: the host's view goes along.
{
let ds = guest.ds_mut().unwrap();
@ -103,29 +103,29 @@ fn following_a_viewport_until_you_move() {
// The host pans: following stops, and later moves of the guest don't move the host.
host.ds_mut().unwrap().view.cx += 100.0;
host.live_sync();
assert_eq!(host.live.follow, None);
assert_eq!(host.live().follow, None);
guest.ds_mut().unwrap().view.cx = -9000.0;
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
h.live().presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
});
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
// Escape stops following too; following someone who isn't there fails.
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
guest.escape().unwrap();
assert_eq!(guest.live.follow, None);
assert_eq!(guest.live().follow, None);
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
// The followed peer leaving ends the follow.
host.execute("collab.follow", &json!({"client": ada})).unwrap();
guest.execute("collab.leave", &json!({})).unwrap();
pump(&mut host, &mut guest, "the leave", |h, _| h.live.follow.is_none() && h.live.peers.is_empty());
pump(&mut host, &mut guest, "the leave", |h, _| h.live().follow.is_none() && h.live().peers.is_empty());
}
#[test]
fn the_host_stopping_ends_the_session_for_guests() {
let (mut host, mut guest, _, _) = host_and_join();
host.execute("collab.stop", &json!({})).unwrap();
pump(&mut host, &mut guest, "the end", |_, g| g.live.status == Status::Off);
assert!(guest.live.peers.is_empty());
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
assert!(guest.live().peers.is_empty());
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
}
@ -138,8 +138,8 @@ fn a_bad_invite_is_reported() {
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
guest.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live.status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live.peers.is_empty());
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live().status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live().peers.is_empty());
}
/// The synced properties of two documents agree (nothing to send from one to the other).
@ -175,7 +175,7 @@ fn edits_sync_between_apps() {
}
};
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
h.live.syncing() && a.live.syncing() && b.live.syncing() && h.live.peers.len() == 2
h.live().syncing() && a.live().syncing() && b.live().syncing() && h.live().peers.len() == 2
});
// The guests got the host's file as a new tab.
assert_eq!(a.docs.len(), 2);
@ -208,7 +208,44 @@ fn edits_sync_between_apps() {
// And the server's: someone joining now gets its document in the welcome.
let mut c = session("Cy");
c.execute("collab.join", &json!({"link": link})).unwrap();
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live.syncing());
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live().syncing());
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
host.execute("collab.stop", &json!({})).unwrap();
}
#[test]
fn cursor_chat_and_spotlight_between_apps() {
let (mut host, mut guest, hana, ada) = host_and_join();
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
// The guest chats next to its cursor; the host sees it, cleaned, and then sees it close.
guest.execute("collab.cursor", &json!({"x": 40, "y": 30})).unwrap();
guest.execute("collab.cursorChat", &json!({"text": "over\there"})).unwrap();
pump(&mut host, &mut guest, "the chat", |h, _| h.live().chats.get(&ada).is_some_and(|t| t == "over here"));
guest.execute("collab.cursorChat", &json!({"text": ""})).unwrap();
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live().chats.is_empty());
// The host spotlights: the guest follows its view, page included.
let second = host.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
pump(&mut host, &mut guest, "the new page at the guest", |_, g| g.doc().unwrap().pages.contains(&second));
assert_eq!(host.execute("collab.spotlight", &json!({})).unwrap()["spotlighting"], true);
{
let ds = host.ds_mut().unwrap();
ds.page = second;
ds.view.cx = 777.0;
ds.view.cy = -55.0;
}
pump(&mut host, &mut guest, "the guest following the spotlight", |_, g| {
let ds = g.ds().unwrap();
g.live().spotlight == Some(hana) && g.live().follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
});
// A late joiner is told and follows too.
let mut late = session("Lin");
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live().follow == Some(hana) && l.live().spotlight == Some(hana));
// The guest moves: it stops following, the spotlight stays; the host stops it for everyone.
guest.ds_mut().unwrap().view.cx += 300.0;
guest.live_sync();
assert_eq!((guest.live().follow, guest.live().spotlight), (None, Some(hana)));
host.execute("collab.spotlight", &json!({"on": false})).unwrap();
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none());
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none());
}

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()
}
}

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);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,134 @@
//! TLS for `wss://` addresses (native): rustls with ring, trusting Mozilla's root certificates
//! (compiled in) plus any in the PEM file `FIGCRAFT_CA_FILE` names (a private authority or a
//! self-signed server). Certificates are always verified.
//!
//! A connection's reader and writer threads share one TLS session ([`Tls`]), each holding it only
//! to decrypt or encrypt: the reader waits for bytes on its own socket handle without it, and a
//! sender takes the socket's write lock first (so records go out in the order they were made),
//! encrypts under the session's lock, then writes with only the socket's. Neither waits on the
//! network while holding the session, so a send stuck behind a full socket never stops the reader
//! from draining what arrives.
use std::io::{self, Read, Write};
use std::net::TcpStream;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{CertificateError, ClientConfig, ClientConnection, RootCertStore};
/// The variable naming a PEM file of extra certificate authorities to trust.
pub const CA_FILE: &str = "FIGCRAFT_CA_FILE";
/// One connection's TLS session, shared by its reader and writer.
pub type Tls = Arc<Mutex<ClientConnection>>;
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Set up TLS on `stream`, connected to `host` (`name:port`), verifying its certificate.
pub fn handshake(stream: &TcpStream, host: &str) -> Result<Tls, String> {
let name = host.rsplit_once(':').map_or(host, |(n, _)| n).trim_start_matches('[').trim_end_matches(']');
let name = ServerName::try_from(name.to_string()).map_err(|_| format!("{name} isn't a name a certificate can be for"))?;
let mut conn = ClientConnection::new(config()?, name).map_err(|e| e.to_string())?;
// Writes are flushed to the socket at once, so the queue never grows.
conn.set_buffer_limit(None);
while conn.is_handshaking() {
conn.complete_io(&mut &*stream).map_err(|e| explain(host, &e))?;
}
Ok(Arc::new(Mutex::new(conn)))
}
/// Why the TLS handshake with `host` failed, in words.
fn explain(host: &str, e: &io::Error) -> String {
let why = |c: &CertificateError| match c {
CertificateError::UnknownIssuer => format!(
"isn't signed by an authority this app trusts (for a private or self-signed certificate, set {CA_FILE} to its authority's PEM file)"
),
CertificateError::NotValidForName | CertificateError::NotValidForNameContext { .. } => "is for another name".into(),
CertificateError::Expired | CertificateError::ExpiredContext { .. } => "has expired".into(),
CertificateError::NotValidYet | CertificateError::NotValidYetContext { .. } => "isn't valid yet (is this computer's clock right?)".into(),
CertificateError::Revoked => "has been revoked".into(),
other => format!("isn't valid ({other:?})"),
};
match e.get_ref().and_then(|i| i.downcast_ref::<rustls::Error>()) {
Some(rustls::Error::InvalidCertificate(c)) => format!("the certificate of {host} {}", why(c)),
Some(other) => format!("no secure connection to {host} ({other}); is a TLS server answering there?"),
None => format!("no secure connection to {host} ({e})"),
}
}
/// The client settings, made once.
fn config() -> Result<Arc<ClientConfig>, String> {
static CONFIG: OnceLock<Result<Arc<ClientConfig>, String>> = OnceLock::new();
CONFIG.get_or_init(make_config).clone()
}
fn make_config() -> Result<Arc<ClientConfig>, String> {
let mut roots = RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Some(path) = std::env::var_os(CA_FILE) {
let shown = std::path::Path::new(&path).display().to_string();
let certs: Vec<CertificateDer> = CertificateDer::pem_file_iter(&path)
.and_then(Iterator::collect)
.map_err(|e| format!("can't read the certificates in {CA_FILE} ({shown}): {e}"))?;
if roots.add_parsable_certificates(certs).0 == 0 {
return Err(format!("{CA_FILE} ({shown}) has no certificate this app can use"));
}
}
#[cfg(test)]
roots.add(CertificateDer::from(&include_bytes!("testdata/test-ca.der")[..])).map_err(|e| e.to_string())?;
let config = ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(Arc::new(config))
}
/// Read plaintext into `buf`: what the session has, else wait for bytes on `sock` (outside the
/// session's lock), take them in and try again. Anything the session must answer (alerts, key
/// updates) goes out through `out`.
pub fn read(tls: &Mutex<ClientConnection>, sock: &mut TcpStream, out: &Mutex<TcpStream>, buf: &mut [u8]) -> io::Result<usize> {
loop {
match lock(tls).reader().read(buf) {
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
done => return done,
}
// Wait for ciphertext without taking it off the socket, then let the session read only
// what it takes: its plaintext buffer is empty here, so it never overflows (feeding it a
// whole chunk could, when a record completes on top of plaintext not yet read).
sock.peek(&mut [0u8; 1])?;
let answer = {
let mut c = lock(tls);
// An empty read (the socket ended) tells the session so: the next read ends too.
c.read_tls(sock)?;
c.process_new_packets().map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
c.wants_write()
};
if answer {
send(tls, out, &[])?;
}
}
}
/// Encrypt and send all of `buf`.
pub fn write(tls: &Mutex<ClientConnection>, out: &Mutex<TcpStream>, buf: &[u8]) -> io::Result<usize> {
send(tls, out, buf)?;
Ok(buf.len())
}
/// Encrypt `plain` and send it after whatever the session has waiting (see the module's docs for
/// the locking).
fn send(tls: &Mutex<ClientConnection>, out: &Mutex<TcpStream>, plain: &[u8]) -> io::Result<()> {
let mut w = lock(out);
let mut records = Vec::new();
{
let mut c = lock(tls);
c.writer().write_all(plain)?;
while c.wants_write() {
c.write_tls(&mut records)?;
}
}
w.write_all(&records)
}

View File

@ -0,0 +1,124 @@
//! Joining over `wss://`: a guest and a managing connection reach a host through a TLS terminator
//! (like the reverse proxy in front of a server) whose certificate comes from a test authority
//! only test builds trust; a certificate for another name is refused with a clear reason.
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::time::Duration;
use figcraft_engine::live::{Event, Status};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::{ServerConfig, ServerConnection, StreamOwned};
use crate::guest::{Guest, GuestConfig};
use crate::invite::{Endpoint, Invite};
use crate::loopback_tests::{Probe, fast_limits, quick, start};
use crate::manage;
/// Serve `localhost`'s test certificate on a port of `localhost` and relay each connection to
/// `upstream`.
fn terminator(upstream: SocketAddr) -> SocketAddr {
let cert = CertificateDer::from(&include_bytes!("testdata/localhost.der")[..]).into_owned();
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(include_bytes!("testdata/localhost-key.der").to_vec()));
let cfg = ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert], key)
.unwrap();
let cfg = Arc::new(cfg);
// The name's first address, which a client tries first (Windows takes seconds to give up on
// a refused one).
let listener = TcpListener::bind("localhost:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for s in listener.incoming().flatten() {
let cfg = cfg.clone();
std::thread::spawn(move || relay(s, upstream, cfg));
}
});
addr
}
/// Copy bytes both ways until either side ends (polling with short timeouts: test code).
fn relay(s: TcpStream, upstream: SocketAddr, cfg: Arc<ServerConfig>) {
let tick = Some(Duration::from_millis(2));
let Ok(mut up) = TcpStream::connect(upstream) else { return };
up.set_read_timeout(tick).unwrap();
s.set_read_timeout(tick).unwrap();
let mut tls = StreamOwned::new(ServerConnection::new(cfg).unwrap(), s);
let mut buf = [0u8; 16 * 1024];
let pass = |r: io::Result<usize>, to: &mut dyn Write, buf: &[u8]| match r {
Ok(0) => false,
Ok(n) => to.write_all(&buf[..n]).and_then(|()| to.flush()).is_ok(),
Err(e) => matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut),
};
loop {
let r = tls.read(&mut buf);
if !pass(r, &mut up, &buf) {
return;
}
let r = up.read(&mut buf);
if !pass(r, &mut tls, &buf) {
return;
}
}
}
fn secure(host: String, invite: &Invite) -> Invite {
Invite { server: Endpoint { host, path: "/".into(), secure: true }, ..invite.clone() }
}
#[test]
fn guests_and_managing_connections_join_over_wss() {
let (mut host, _hp, invite) = start();
let front = terminator(host.addr());
let wss = secure(format!("localhost:{}", front.port()), &invite);
assert!(wss.url().starts_with("wss://localhost:"));
let mut probe = Probe::new();
let mut g = Guest::start(quick(GuestConfig::new(wss.clone(), "Ada".into())), probe.inbox.clone()).unwrap();
probe.wait("the welcome over TLS", |e| e.iter().any(|e| matches!(e, Event::Welcome { .. })));
// The managing path speaks TLS too: an app's host answers, and refuses to manage files.
let err = manage::request(&wss.server, "key", Vec::new(), &fast_limits(), Duration::from_secs(5)).unwrap_err();
assert!(err.contains("hosted by an app"), "{err}");
g.leave();
host.stop();
}
#[test]
fn a_certificate_for_another_name_is_refused() {
let (mut host, _hp, invite) = start();
let front = terminator(host.addr());
let mut probe = Probe::new();
let _g = Guest::start(quick(GuestConfig::new(secure(front.to_string(), &invite), "Ada".into())), probe.inbox.clone()).unwrap();
probe.wait("the refusal", |e| {
e.iter()
.any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains(&format!("certificate of {front} is for another name"))))
});
host.stop();
}
#[test]
fn reader_and_writer_share_one_session_without_blocking_each_other() {
// An echo server behind the terminator: more than any socket buffers hold, written in one go,
// comes back while it is still being sent (were the reader to wait for the writer, both would
// stall once the buffers fill).
let echo = TcpListener::bind("127.0.0.1:0").unwrap();
let upstream = echo.local_addr().unwrap();
std::thread::spawn(move || {
let (mut s, _) = echo.accept().unwrap();
let _ = io::copy(&mut s.try_clone().unwrap(), &mut s);
});
let front = terminator(upstream);
let server = Endpoint { host: format!("localhost:{}", front.port()), path: "/".into(), secure: true };
let (mut rd, mut wr, _sock) = crate::net::open(&server, Duration::from_secs(5), &fast_limits()).unwrap();
rd.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let sent: Vec<u8> = (0..8 << 20).map(|i: u32| (i % 251) as u8).collect();
let expect = sent.clone();
let writer = std::thread::spawn(move || wr.write_all(&sent).unwrap());
let mut got = vec![0u8; expect.len()];
rd.read_exact(&mut got).unwrap();
writer.join().unwrap();
assert!(got == expect);
}

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))
}

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);
}
}
}

View File

@ -87,7 +87,8 @@ macro_rules! node_props {
($($variant:ident => $field:ident),* $(,)?) => {
/// A node property: the tree position (`parent`, `position`) or one saved field of
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
/// replaced whole. New properties go at the end (the binary form numbers them).
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Prop {
@ -177,6 +178,8 @@ node_props! {
PropRefs => prop_refs,
PluginData => plugin_data,
Annotations => annotations,
CodeLinks => code_links,
DevResources => dev_resources,
}
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,

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