Compare commits

...

64 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
313 changed files with 24840 additions and 2264 deletions

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,7 +11,7 @@ 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 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 |

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`

54
Cargo.lock generated
View File

@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"arboard",
"eframe",
@ -1430,7 +1430,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@ -1444,7 +1444,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@ -1459,7 +1459,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@ -1482,7 +1482,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"kurbo",
"serde",
@ -1491,7 +1491,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-color",
"figcraft-geom",
@ -1503,7 +1503,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@ -1530,9 +1530,10 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
"flate2",
"image",
"ruzstd",
@ -1544,7 +1545,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"kurbo",
"serde",
@ -1552,7 +1553,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@ -1560,7 +1561,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@ -1576,7 +1577,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@ -1585,7 +1586,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@ -1602,7 +1603,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"figcraft-color",
"serde",
@ -1615,7 +1616,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"crc32fast",
"figcraft-color",
@ -1633,7 +1634,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"crc32fast",
"ctrlc",
@ -1657,7 +1658,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@ -1666,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",
@ -1684,7 +1687,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"egui",
@ -1707,7 +1710,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.3.1"
version = "0.5.1"
dependencies = [
"eframe",
"egui",
@ -3922,9 +3925,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
[[package]]
name = "ryu"
@ -5803,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",
]

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"
@ -59,7 +59,7 @@ linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"

View File

@ -70,7 +70,7 @@
</td>
<td width="25%" valign="top">
<h3>🔌 Offline and private</h3>
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no telemetry, and nothing leaves your computer unless you share a file (<a href="#privacy-and-online-sharing">what sharing sends where</a>). SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
</td>
<td width="25%" valign="top">
<h3>🤖 Agent-ready</h3>
@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
@ -229,8 +229,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **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. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
@ -251,6 +251,10 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
git clone https://github.com/storytold/craft-fonts ../craft-fonts
```
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
@ -260,6 +264,31 @@ Installers for macOS (universal, signed and notarized), Windows (x64, x86 and ar
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
FigCraft works offline: it has no account and no telemetry, and it contacts no server until you
share or join a file. Live sessions are a **preview**. Before you share anything that matters,
know what sharing does:
- **Share ▸ Online uploads the file to a server.** By default that is the public server at
`collab.craftapps.dev`; you can set another one, or your own, in Preferences (and a build can
ship without it: [`docs/releasing.md`](docs/releasing.md#the-default-server)). The file stays on
the server after you stop sharing, until you remove it (Share ▸ Advanced ▸ Remove from server…)
or nobody has opened it for 30 days, when a public server deletes it.
- **It is not end-to-end encrypted.** The connection to the server is encrypted (TLS), but the
server stores the file unencrypted and whoever runs the server can read it. Don't share
confidential work through a server you don't trust; run your own
([`docs/server.md`](docs/server.md)) or share on the local network instead.
- **The code or link is the access.** There are no accounts: anyone who has an edit code or link
can open and change the file, anyone with a view code or link can open it. Stop sharing revokes
them and disconnects everyone else.
- **Share ▸ Local network involves no server**, but it is not encrypted either: your app hosts
the session and others connect to it directly, so use it on networks you trust.
- **No guarantees.** The public server is offered as is, without backups or uptime promises, and
files on it can be removed. Keep your own saved copy of anything you share.
The default server's terms are in [`docs/online-sharing-terms.md`](docs/online-sharing-terms.md).
## The Crafting Apps
FigCraft is one of the **Crafting Apps**: free, open-source creative tools from the

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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,10 +13,10 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (207 / 207) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
@ -26,8 +26,8 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) |
| Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@ -58,7 +58,7 @@ native speakers' reviews of the six language drafts need people.
|---|---|---|---|
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, checked only through our own importer) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
@ -100,15 +100,15 @@ native speakers' reviews of the six language drafts need people.
|---|---|---|---|
| English | en | 100% | full |
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 87 entries) | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| French | fr | 99% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 99% | draft (needs native review) |
| French | fr | 100% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
| Indonesian | id | 0% | none |
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 99% | draft (needs native review) |
| Korean | ko | 99% | draft (needs native review) |
| Japanese | ja | 100% | draft (needs native review) |
| German | de | 100% | draft (needs native review) |
| Korean | ko | 100% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
@ -123,7 +123,7 @@ IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-pa
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the experimental `.fig` export (landed 2026-10-12) on real files: 5–15 h.
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
@ -131,12 +131,65 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## 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
@ -257,6 +310,26 @@ 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 |

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

@ -102,8 +102,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
}
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.map_err(|e| CliError::Failed(e.to_string()))?
.into_bytes(),
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
@ -379,4 +379,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

@ -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

@ -5,10 +5,16 @@
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
//! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood
//! of addresses costs bounded memory (and forgotten addresses start with full buckets).
//!
//! Join codes are short, so guessing them is held back three ways: an address may try
//! [`CODES_PER_MINUTE`] codes a minute, [`WRONG_CODES`] wrong ones in a row lock it out of codes for
//! [`CODE_LOCKOUT`] (links still join), and everyone together may get [`WRONG_CODES_PER_HOUR`]
//! wrong an hour (an attacker with many addresses is slowed to that; while it lasts, people join
//! with their links).
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::Instant;
use std::time::{Duration, Instant};
use figcraft_collab_net::hub::{Block, ip_blocks};
use figcraft_collab_net::net::Bucket;
@ -28,6 +34,15 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
/// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all.
pub const PUBLISHES_ALL: f64 = 32.0;
/// Join codes one address may try a minute (right or wrong).
pub const CODES_PER_MINUTE: f64 = 10.0;
/// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes.
pub const WRONG_CODES: u32 = 10;
/// How long an address is locked out of join codes.
pub const CODE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
/// Wrong codes everyone together may try an hour.
pub const WRONG_CODES_PER_HOUR: f64 = 1_000.0;
/// The rates (see the server's settings).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AbuseConfig {
@ -49,6 +64,8 @@ pub enum Action {
Report,
/// Downloading this many bytes.
Download(usize),
/// Trying a join code.
Code,
}
struct Address {
@ -58,6 +75,12 @@ struct Address {
presence: Bucket,
reports: Bucket,
downloads: Bucket,
codes: Bucket,
/// Wrong join codes in a row, and when the last one was.
wrong: u32,
wrong_at: Instant,
/// Locked out of join codes until then.
locked: Option<Instant>,
used: Instant,
}
@ -67,6 +90,8 @@ pub struct Abuse {
table: HashMap<Block, Address>,
/// Publishing without the key, everyone together.
publishes: Bucket,
/// Wrong join codes, everyone together.
wrong_codes: Bucket,
}
/// `n` every `period` seconds on average, and all `n` at once at most.
@ -77,7 +102,44 @@ fn bucket(n: f64, period: f64) -> Bucket {
impl Abuse {
pub fn new(cfg: AbuseConfig) -> Self {
let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0);
Self { cfg, table: HashMap::new(), publishes }
Self { cfg, table: HashMap::new(), publishes, wrong_codes: bucket(WRONG_CODES_PER_HOUR, 3600.0) }
}
/// How long `ip` is still locked out of join codes (in any block it is in), if it is.
pub fn code_locked(&self, ip: IpAddr, now: Instant) -> Option<Duration> {
let until = ip_blocks(ip).into_iter().filter_map(|(k, _)| self.table.get(&k)?.locked).max()?;
Some(until.saturating_duration_since(now)).filter(|d| !d.is_zero())
}
/// Everyone together has wrong codes left to try (see [`WRONG_CODES_PER_HOUR`]).
pub fn codes_open(&mut self, now: Instant) -> bool {
let open = self.wrong_codes.take(now);
if open {
self.wrong_codes.give(1.0);
}
open
}
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
/// before it; enough wrong ones in a row lock the address out.
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
if !ok {
self.wrong_codes.take(now);
}
for (k, times) in ip_blocks(ip) {
let a = self.address(k, f64::from(times), now);
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
a.wrong += 1;
a.wrong_at = now;
if a.wrong >= WRONG_CODES.saturating_mul(times) {
a.wrong = 0;
a.locked = Some(now + CODE_LOCKOUT);
}
}
}
}
/// Whether `ip` may do `what` now (counting it, in every block it is in, when it may).
@ -114,6 +176,10 @@ impl Abuse {
presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0),
reports: bucket(REPORTS_PER_HOUR * times, 3600.0),
downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times),
codes: bucket(CODES_PER_MINUTE * times, 60.0),
wrong: 0,
wrong_at: now,
locked: None,
used: now,
});
a.used = now;
@ -128,6 +194,7 @@ impl Abuse {
Action::Presence => (&mut a.presence, 1.0),
Action::Report => (&mut a.reports, 1.0),
Action::Download(n) => (&mut a.downloads, n as f64),
Action::Code => (&mut a.codes, 1.0),
}
}

View File

@ -344,7 +344,7 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
if rotate {
out.push_str("A new server key: the old one no longer works.\n");
}
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
let _ = writeln!(out, "Publish to this address (in FigCraft: File > Publish to server…):");
let _ = writeln!(out, " {}?key={key}", public.url());
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
if cfg.open_publish {
@ -375,7 +375,7 @@ fn used(f: &FileRow) -> u64 {
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
if files.is_empty() {
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
return "No files yet (publish one from FigCraft: File > Publish to server…).\n".into();
}
match sort {
Sort::Id => {}

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

@ -235,6 +235,21 @@ fn a_file_cant_grow_past_its_cap_through_batches() {
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
drop(raw);
server.stop();
// Everything confirmed opens again with the same settings, after compacting on the way out.
let kept = client.document().nodes().count();
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
let welcome = loop {
match raw.next() {
w @ ServerMsg::Welcome { .. } => break w,
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
_ => {}
}
};
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
server.stop();
}

View File

@ -10,6 +10,7 @@
pub mod abuse;
pub mod cli;
pub mod codes;
pub mod config;
pub mod directory;
pub mod disk;
@ -90,12 +91,16 @@ mod background_tests;
#[cfg(test)]
mod cli_tests;
#[cfg(test)]
mod codes_tests;
#[cfg(test)]
mod directory_tests;
#[cfg(test)]
mod hardening_tests;
#[cfg(test)]
mod library_tests;
#[cfg(test)]
mod owned_tests;
#[cfg(test)]
mod public_tests;
#[cfg(test)]
mod server_tests;

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

@ -60,8 +60,8 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
assert_eq!(info["idleDays"], 30.0);
// Published without a key: links, an owner token shown once, and an expiry date.
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some() && s[0].live.hosting.is_some());
let p = a.live.published.clone().unwrap();
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live().hosting.is_some());
let p = a.live().published.clone().unwrap();
let owner = p.owner.clone().expect("an owner token");
assert_eq!(owner.len(), 22, "128 bits");
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
@ -73,7 +73,7 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
// Someone joining hears about the hosting too (with the file's expiry).
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live.hosting.as_ref().is_some_and(|h| h.expires.is_some()));
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live().hosting.as_ref().is_some_and(|h| h.expires.is_some()));
// The owner lists, makes, revokes links and renames the file.
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
@ -98,9 +98,9 @@ fn anyone_publishes_and_the_owner_token_manages_the_file() {
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
raw.send(&ClientMsg::Admin(Admin::Listing));
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
// Removing it disconnects everyone in it.
// Removing it disconnects everyone in it (whoever removed it just leaves: no failure there).
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
pump(&mut [&mut a, &mut b], "everyone told", |s| s.iter().all(|x| x.live_summary()["status"] == "failed"));
pump(&mut [&mut a, &mut b], "everyone told", |s| s[0].live_summary()["status"] == "off" && s[1].live_summary()["status"] == "failed");
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
assert!(!dir.path().join("files").join(&p.file).exists());
server.stop();
@ -260,10 +260,10 @@ fn participants_report_files_to_the_operator() {
let url = format!("ws://{}/", server.addr());
let mut a = session("Ada");
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
let mut b = join(&p.view, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live.hosting.is_some());
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live().hosting.is_some());
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));

View File

@ -34,8 +34,8 @@ fn publish_then_edit_and_view_together() {
// Nothing changes it until it is on the server.
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
assert!(e.contains("being published"), "{e}");
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
let p = a.live.published.clone().unwrap();
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
let p = a.live().published.clone().unwrap();
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
assert_eq!(a.live_summary()["readOnly"], false);
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
@ -43,7 +43,7 @@ fn publish_then_edit_and_view_together() {
// Bo joins with the edit link, Cy with the view link.
let mut b = join(&p.edit, "Bo");
let mut c = join(&p.view, "Cy");
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live().peers.len() == 2);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(c.live_summary()["access"], "view");
assert_eq!(c.live_summary()["readOnly"], true);
@ -64,9 +64,9 @@ fn publish_then_edit_and_view_together() {
assert!(c.begin_interaction("Move").is_err());
assert!(same(&before, c.doc().unwrap()));
// Cy is still seen: presence goes both ways.
let cy = c.live.info.as_ref().unwrap().me;
let cy = c.live().info.as_ref().unwrap().me;
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live().presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
// A client that skips the app's check is refused by the server.
let view = Invite::parse(&p.view).unwrap();
let mut raw = Raw::connect(addr);
@ -90,7 +90,7 @@ fn publish_then_edit_and_view_together() {
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
// Leaving keeps a local copy.
c.execute("collab.leave", &json!({})).unwrap();
assert_eq!(c.live.status, Status::Off);
assert_eq!(c.live().status, Status::Off);
assert!(c.doc().unwrap().contains(rect));
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
@ -106,21 +106,21 @@ fn a_restart_mid_session_loses_no_confirmed_edit() {
let mut a = session("Ada");
let p = publish(&mut a, dir.path(), addr);
let mut b = join(&p.edit, "Bo");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
let (ada, bo) = (a.live().info.as_ref().unwrap().me, b.live().info.as_ref().unwrap().me);
// The server stops: both reconnect, and what Ada makes meanwhile waits.
server.stop();
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live().status, Status::Reconnecting { .. })));
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
cfg.listen = addr.to_string();
let mut server = start_again(&cfg);
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
});
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
assert_eq!(a.live().info.as_ref().unwrap().me, ada, "Ada resumed as herself");
assert_eq!(b.live().info.as_ref().unwrap().me, bo, "so did Bo");
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
// Someone new gets it all from the restarted server.
let mut c = join(&p.view, "Cy");
@ -160,20 +160,20 @@ fn revoked_links_stop_working_at_once() {
assert_eq!((files.len(), files[0].people), (1, 3));
// Revoked: Cy is out at once, the others stay.
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
});
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
assert!(live(&b));
// The link no longer joins.
c.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live().status, Status::Failed { reason } if reason.contains("isn't valid")));
// Managing needs the key.
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
assert!(e.contains("wrong server key"), "{e}");
// Removing the file ends it for everyone in it.
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b], "everyone out", |s| {
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
s.iter().all(|x| matches!(&x.live().status, Status::Failed { reason } if reason.contains("removed")))
});
server.stop();
}

View File

@ -6,7 +6,8 @@
//! <data>/server.json where the running server listens (for the command line)
//! <data>/server.key the server key: managing files (publish, links, removal)
//! <data>/files/<id>/meta.json name, created, updated, sequence number
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token,
//! and a join code's hash and wrapped token (see `codes`)
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
//! <data>/files/<id>/log.bin what changed since (see `disk`)
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
@ -28,7 +29,7 @@
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
//! never deleted.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::{Cursor, Write};
use std::net::IpAddr;
@ -36,8 +37,8 @@ use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use figcraft_collab::message::{
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, NewLink, PluginReply,
PluginReq, ReportInfo, Usage,
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, MAX_OWNED, NewLink, OwnedFile,
PluginReply, PluginReq, ReportInfo, Usage,
};
use figcraft_collab::{Change, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
@ -46,6 +47,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::abuse::{Abuse, AbuseConfig, Action};
use crate::codes::{self, Sealed};
use crate::directory::{Directory, DirectoryConfig};
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
@ -58,6 +60,12 @@ pub const REPORTS: &str = "reports.json";
/// Most addresses blocked, and most reports kept (the oldest go first).
const MAX_BLOCKED: usize = 100_000;
const MAX_REPORTS: usize = 1_000;
/// Most join codes remembered as no longer valid.
const MAX_GONE_CODES: usize = 10_000;
/// What typing a join code that opens nothing is answered with, by why.
pub const NO_CODE: &str = "No session with that code";
pub const CODE_GONE: &str = "That code has expired or its sharing was stopped";
pub const CODES_BUSY: &str = "Join codes are paused on this server for a while (too many wrong ones were tried): join with the invite link";
/// Longest report reason kept (characters).
const MAX_REASON: usize = 500;
/// How often idle files are looked for (seconds), besides at startup.
@ -188,6 +196,9 @@ struct Link {
created: u64,
/// SHA-256 of the token, hex.
hash: String,
/// Its join code, when one was handed out (never the code itself).
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<Sealed>,
}
impl Link {
@ -245,6 +256,11 @@ pub struct Store {
usage: BTreeMap<String, UsageCache>,
blocked: BTreeSet<IpAddr>,
reports: Vec<ReportInfo>,
/// Hashes of join codes that no longer open anything (their link was revoked, their file
/// removed or expired, or a newer code took their place): typing one says so.
// shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the
// data directory if people ask why their code "never existed".
gone_codes: VecDeque<String>,
/// When idle files are looked for next (seconds since 1970).
next_sweep: u64,
/// Compactions so far (tests count them).
@ -341,6 +357,7 @@ impl Store {
usage: BTreeMap::new(),
blocked: BTreeSet::new(),
reports: Vec::new(),
gone_codes: VecDeque::new(),
next_sweep: 0,
compactions: 0,
free: None,
@ -409,7 +426,7 @@ impl Store {
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec<Link> =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.snapshot_cap())?)?;
// A damaged log only loses its end; it is reported when the file opens.
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
&& let Ok(log) = disk::read_log(&bytes)
@ -604,6 +621,26 @@ impl Store {
.collect()
}
/// Owner token `owner` manages file `file` (one published without the key).
fn owns(&self, file: &str, owner: &str) -> bool {
let presented = hash(owner);
self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
}
/// What became of the files these `(file id, owner token)` pairs own: each one's name, links
/// and expiry, or `None` when that token manages no file of that id here (which doesn't say
/// whether the id exists). Asking opens nothing: it doesn't count as using a file.
pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
if files.len() > MAX_OWNED {
return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
}
let one = |(file, owner): &(String, String)| {
let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
};
AdminReply::Owned(files.iter().map(one).collect())
}
/// Rename a file (its listing; the document inside keeps its own name).
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
let name = clean_name(name);
@ -755,6 +792,101 @@ impl Store {
Ok(NewLink { link: link.info(file), token })
}
/// The link of `entry` whose token `token` is (every link is compared, in constant time, so
/// timing doesn't tell which one matched).
fn link_of(entry: &Entry, token: &str) -> Option<usize> {
let presented = hash(token);
let mut found = None;
for (i, l) in entry.links.iter().enumerate() {
if same_secret(&presented, &l.hash) {
found = Some(i);
}
}
found
}
/// These join codes no longer open anything: typing one says so.
fn forget_codes(&mut self, codes: impl IntoIterator<Item = Sealed>) {
self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
self.gone_codes.drain(..extra);
}
/// Where the link with the join code `key` is: its file, its place among the file's links
/// and its token (every code is compared, so timing doesn't tell which one matched).
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
let mut found = None;
for (id, e) in &self.files {
for (i, l) in e.links.iter().enumerate() {
if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
found = Some((id.clone(), i, token));
}
}
}
found
}
/// A new join code for the link of `file` whose token is `token` (it takes the place of the
/// link's earlier code): the code, shown this once.
pub fn set_code(&mut self, file: &str, token: &str) -> Result<String, String> {
let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
// 45 random bits: one already in use is all but impossible, and never handed out twice.
let (code, key) = loop {
let code = codes::new_code()?;
let key = codes::Key::new(&code);
if self.find_code(&key).is_none() {
break (code, key);
}
};
let sealed = key.seal(token)?;
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
let before = link.code.replace(sealed);
let id = link.id.clone();
if let Err(e) = write_json(&path, &entry.links) {
if let Some(l) = entry.links.get_mut(at) {
l.code = before;
}
return Err(format!("couldn't keep the join code: {e}"));
}
self.forget_codes(before);
log::info!("event=code file={file} link={id}");
Ok(code)
}
/// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
/// the address (see `abuse`), and nothing is looked up for an address that is locked out.
pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
let now = Instant::now();
if let Some(left) = self.abuse.code_locked(ip, now) {
return Err(format!(
"Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
left.as_secs() / 60 + 1
));
}
if !self.abuse.allow(ip, Action::Code, now) {
return Err("Too many codes tried from your address: wait a minute and try again".into());
}
if !self.abuse.codes_open(now) {
return Err(CODES_BUSY.into());
}
// Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
let found = key.as_ref().and_then(|k| self.find_code(k));
self.abuse.code_tried(ip, found.is_some(), now);
match found {
Some((file, _, token)) => {
log::info!("event=code_used file={file} ip={ip}");
Ok((file, token))
}
None => {
let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
Err(if gone { CODE_GONE } else { NO_CODE }.into())
}
}
}
/// Revoke a link.
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
let path = self.dir(file).join(LINKS);
@ -766,6 +898,7 @@ impl Store {
return Err(format!("couldn't revoke the link: {e}"));
}
log::info!("revoked link {link} of file {file}");
self.forget_codes(gone.code);
Ok(())
}
@ -774,6 +907,7 @@ impl Store {
pub fn remove(&mut self, file: &str) -> Result<(), String> {
// Closed first: an open log would keep the folder from being deleted on Windows.
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
drop(entry);
let dir = self.dir(file);
fs::remove_dir_all(&dir)
@ -952,7 +1086,7 @@ impl Store {
fn load(&mut self, file: &str) -> Result<Server, String> {
self.touch(file);
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
let mut issues = Vec::new();
let mut fresh_log = true;
@ -996,11 +1130,22 @@ impl Store {
Ok(server)
}
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
/// document (recent batches for reconnecting clients, deletions), which may take it over.
fn snapshot_cap(&self) -> u64 {
self.cfg.max_file_bytes.saturating_mul(2)
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
let dir = self.dir(file);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(generation, &state)?;
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
// and log still hold everything.
if bytes.len() as u64 > self.snapshot_cap() {
return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20));
}
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}
@ -1112,21 +1257,15 @@ fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), Strin
/// A new link and its token.
fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> {
let token = token::secret()?;
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token))
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token))
}
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
let entry = self.entry(file)?;
let presented = hash(token);
// Every link is compared (in constant time), so timing doesn't tell which one matched.
let mut found = None;
for l in &entry.links {
if same_secret(&presented, &l.hash) {
found = Some(Admission { access: l.access, link: Some(l.id.clone()) });
}
}
found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
}
fn open(&mut self, file: &str) -> Result<Server, String> {
@ -1234,9 +1373,7 @@ impl Backend for Store {
if file.is_empty() && owner.is_empty() {
return Ok(self.host_info(None));
}
let presented = hash(owner);
let owns = self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h));
if !owns {
if !self.owns(file, owner) {
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
}
Ok(self.host_info(Some(file)))
@ -1257,10 +1394,12 @@ impl Backend for Store {
}
};
}
Admin::Owned { files } => return self.owned(files).into(),
Admin::Links { file }
| Admin::CreateLink { file, .. }
| Admin::Revoke { file, .. }
| Admin::Rename { file, .. }
| Admin::Code { file, .. }
| Admin::Remove { file } => file,
_ => return failed("that needs the server key"),
};
@ -1297,6 +1436,8 @@ impl Backend for Store {
},
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
Admin::Owned { files } => self.owned(&files).into(),
Admin::Remove { file } => {
if !self.files.contains_key(&file) {
return failed("this file isn't on the server (it may have been removed)".into());
@ -1342,6 +1483,10 @@ impl Backend for Store {
self.directory.handle(publisher, req)
}
fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
self.resolve_code(ip, code)
}
fn closing(&self) -> Closing {
Closing::Restarting
}

View File

@ -91,7 +91,7 @@ pub fn key(dir: &Path) -> String {
}
pub fn live(s: &Session) -> bool {
s.live.status == Status::Live && s.live.syncing()
s.live().status == Status::Live && s.live().syncing()
}
/// `a` publishes its open file to the server at `addr`; the links.
@ -99,8 +99,8 @@ pub fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
a.live.published.clone().unwrap()
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
a.live().published.clone().unwrap()
}
pub fn join(link: &str, name: &str) -> Session {

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,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

@ -12,12 +12,17 @@
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;
@ -49,9 +54,7 @@ impl eframe::App for App {
}
}
let title = with_app(|fig| {
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
fig.control_queue.extend(q.drain(..));
drop(q);
control_server::drain_queue(&self.queue, &mut fig.control_queue);
// 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);
@ -129,24 +132,34 @@ fn main() -> eframe::Result {
}
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
let recovery: recovery::Shared = Arc::new(Mutex::new(recovery_root.as_deref().and_then(recovery::Recovery::start)));
// The preferences first: they hold the online files a recovered document may be one of.
let recovery = recovery::Shared::default();
let svc = services::Desktop::load(recovery.clone());
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
let recovered = recovery_root.as_deref().map(|r| recovery::recover_all(r, &mut session)).unwrap_or_default();
let mut copies = recovery_root.as_deref().and_then(recovery::Recovery::start);
// Copies of online files stay until their files arrive from their servers.
if let Some(r) = copies.as_mut() {
r.adopt(recovered.waiting);
}
*recovery::lock(&recovery) = copies;
// Plugins installed from a server's plugin directory live with the user's settings.
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
// Invite links open this app (Windows registers per user at run time; installed builds by
// default, never a build from source unless asked).
session.live.links = figcraft_collab_net::links::system();
session.invite_links_startup();
if session.docs.is_empty() {
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
// tips and templates; later runs start in a new file.
let first_run = svc.prefs.is_none() && args.join.is_none();
if session.docs.is_empty() && !first_run {
let _ = session.execute("file.new", &json!({}));
}
let mut fig = FigApp::new(session, Box::new(svc));
fig.ui.home = false;
fig.note_recovered(recovered);
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
}
@ -172,7 +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() };
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

@ -5,6 +5,16 @@
//! 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;
@ -35,6 +45,25 @@ pub struct Recovery {
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 {
@ -44,29 +73,61 @@ impl Recovery {
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 })
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);
return pending.then_some(self.next - now).or(settling);
}
self.write(s, false);
self.next = now + INTERVAL;
None
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;
}
@ -76,7 +137,20 @@ impl Recovery {
continue;
}
let doc = d.doc.clone();
let job = move || write_atomic(&path, &doc);
// 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;
@ -92,12 +166,14 @@ impl Recovery {
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.
/// 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.
@ -109,35 +185,65 @@ impl Recovery {
}
}
/// 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()
}
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return };
/// 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");
if std::fs::write(&tmp, bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
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. Returns how many documents came back.
pub fn recover(root: &Path, s: &mut Session) -> usize {
let Ok(entries) = std::fs::read_dir(root) else { return 0 };
let mut n = 0;
let mut open = |file: &Path, s: &mut Session| {
if file.extension().is_some_and(|e| e == "figcraft") && s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
n += 1;
/// 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, s);
open(&path, None, s);
continue;
}
// Still running (another window of the app), or not ours to take.
@ -145,19 +251,26 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
if lock.try_lock().is_err() {
continue;
}
let lock = Arc::new(lock);
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
if f.extension().is_some_and(|e| e == "tmp") {
// A copy cut off mid-write; the one before it is still there.
let _ = std::fs::remove_file(f);
} else {
open(f, s);
open(f, Some(&lock), s);
}
}
drop(lock);
// Empty now unless a copy couldn't be read (kept for the next start).
let _ = std::fs::remove_file(path.join(LOCK));
let _ = std::fs::remove_dir(&path);
// The notes go with their copies (one whose copy stays, stays with it).
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
let _ = std::fs::remove_file(f);
}
// Empty now unless a copy couldn't be read, or 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);
}
}
n
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

@ -141,17 +141,31 @@ pub fn save_state(app: &FigApp) {
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
/// either version whole).
fn write_prefs(dir: &Path, prefs: &Prefs) {
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
let tmp = dir.join("state.json.tmp");
let written = std::fs::create_dir_all(dir)
.and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string()))
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
if let Err(e) = written {
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
}
}
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
/// file from a crash is narrowed too: the mode given at open only applies to new files).
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o.open(path)?;
#[cfg(unix)]
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
f.write_all(bytes)
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
@ -210,7 +224,11 @@ impl Services for Desktop {
/// The window icon from the bundled app icon (if present).
pub fn window_icon() -> Option<egui::IconData> {
// macOS wants Apple's padded grid; the other platforms the rounded tile edge to edge.
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");
#[cfg(not(target_os = "macos"))]
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png");
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })

View File

@ -66,3 +66,19 @@ fn plugins_are_kept_when_the_app_is_killed() {
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn preferences_are_readable_by_their_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = root("private");
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
// An older world-readable file and a leftover temporary one from a crash.
for name in ["state.json", "state.json.tmp"] {
std::fs::write(dir.join(name), "{}").unwrap();
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
}
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}

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

@ -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

@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@ -39,6 +39,8 @@ pub struct Publish {
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
pub share: bool,
}
/// A joined (or joining) session. Dropping it leaves.
@ -60,10 +62,27 @@ enum Cmd {
impl Guest {
/// Start joining (returns at once; progress arrives in `inbox`).
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
pub fn start(mut cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
let (tx, rx) = channel();
let me = tx.clone();
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || supervise(&cfg, &inbox, &rx, &me))?;
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || {
// A join code: the server says which file it opens, then the join goes on as with a link.
if let Some(code) = cfg.code.clone() {
inbox.push(Event::Status(Status::Connecting));
let asked =
manage::resolve(&cfg.invite.server, &code, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
Some(why) => Outcome::Refused(why.to_string()),
None => Outcome::Retry(e),
});
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
inbox.push(e);
}
return;
}
}
supervise(&cfg, &inbox, &rx, &me);
})?;
Ok(Guest { cmd: tx, thread: Some(thread) })
}
@ -79,10 +98,21 @@ impl Guest {
Ok(done) => done,
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
};
// Before anyone hears the file is there, so what others join with shows with it. The
// file's first links stay its owner's (this app joins with one): others get their own,
// which stopping takes away again.
let shared = job.share.then(|| {
let owner = published.owner.as_deref().unwrap_or_default();
let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout);
made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))
});
if let Some(h) = hosting {
inbox.push(Event::Hosting(h));
}
inbox.push(Event::Published(published));
if let Some(shared) = shared {
inbox.push(Event::Shared(shared));
}
// Left while uploading: the file stays on the server, nobody joins it. Anything else
// that came meanwhile (presence) waits for the session.
let mut waiting = Vec::new();
@ -191,7 +221,7 @@ fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
}
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
let resume: Arc<Resuming> = Arc::default();
let resume: Arc<Resuming> = Arc::new(std::sync::Mutex::new(cfg.resume));
let mut attempt = 0u32;
let mut was_live = false;
let mut pending: Option<Presence> = None;

View File

@ -32,7 +32,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{
self, Access, Admin, AdminReply, ClientMsg, Hello, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Presence, ServerMsg, reads_hosting,
PluginReply, PluginReq, Presence, Resolve, ServerMsg, reads_hosting,
};
use figcraft_collab::{Outgoing, Server};
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
@ -205,6 +205,12 @@ pub trait Backend: Send + 'static {
Err("this session is hosted by an app: tell the person hosting it".into())
}
/// A join code typed by someone at `ip`: the file it opens and that link's token, or why not
/// (shown to them; wrong codes are counted against the address).
fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> {
Err("this session is hosted by an app: join it with its own code or invite link".into())
}
/// What everyone is told when the hub stops.
fn closing(&self) -> Closing;
}
@ -386,8 +392,8 @@ impl InFlight {
}
enum HubIn {
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins` or
/// `OpenHosting`) and its client's address ([`ip_key`]).
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`,
/// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]).
Open {
conn: u64,
ip: IpAddr,
@ -907,7 +913,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
| ClientMsg::Manage(_)
| ClientMsg::OpenLibrary(_)
| ClientMsg::OpenPlugins(_)
| ClientMsg::OpenHosting(_)),
| ClientMsg::OpenHosting(_)
| ClientMsg::Resolve(_)),
bytes,
))) => {
// Sessions and the operator may use the last connections; one-shot requests
@ -1097,6 +1104,10 @@ fn unsupported(protocol: u32) -> String {
format!("protocol {protocol} isn't supported (this server speaks {PROTOCOL})")
}
/// What someone in a file is told when the link they joined with is revoked (its owner stopped
/// sharing): the session's end for them.
pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open";
/// What everyone in a file is told when its changes couldn't be saved (they reconnect).
const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting";
@ -1171,6 +1182,7 @@ impl<B: Backend> Hub<B> {
ClientMsg::OpenLibrary(o) => self.open_library(conn, o, c),
ClientMsg::OpenPlugins(o) => self.open_plugins(conn, &o, c),
ClientMsg::OpenHosting(o) => self.open_hosting(conn, &o, c),
ClientMsg::Resolve(r) => self.resolve(&r, &c),
_ => refuse(&c.out, "say hello first"),
}
}
@ -1408,6 +1420,26 @@ impl<B: Backend> Hub<B> {
self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info)));
}
/// A join code: the file it opens and the token to join it with, then the connection closes
/// (it was never taken in).
fn resolve(&mut self, r: &Resolve, c: &Pending) {
if r.protocol != PROTOCOL {
return refuse(&c.out, &unsupported(r.protocol));
}
match self.backend.resolve(c.ip, &r.code) {
Ok((file, token)) => {
if let Some(b) = encode(&ServerMsg::Resolved { file, token }) {
let _ = c.out.tx.try_send(Out::Bytes(b, None));
}
let _ = c.out.tx.try_send(Out::Close(CloseCode::Normal, String::new()));
}
Err(why) => {
log::info!("event=code_refused ip={} reason={why:?}", c.ip);
refuse(&c.out, &why);
}
}
}
fn admin(&mut self, conn: u64, req: Admin) {
let rooms = &self.rooms;
let open = |file: &str| rooms.get(file).map(Room::info);
@ -1419,15 +1451,24 @@ impl<B: Backend> Hub<B> {
fn handled(&mut self, conn: u64, handled: Handled) {
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
if let Some((file, link)) = handled.revoked {
let users: Vec<u64> = self
.conns
.iter()
.filter(|(_, c)| {
matches!(&c.kind, Kind::Joined { file: f, link: Some(l), .. } | Kind::Library { file: f, link: Some(l), .. } if *f == file && *l == link)
})
.map(|(k, _)| *k)
.collect();
self.kick(&users, "the link you joined with was revoked");
let with = |joined: bool| -> Vec<u64> {
let uses = |c: &Conn| match &c.kind {
Kind::Joined { file: f, link: Some(l), .. } => joined && *f == file && *l == link,
Kind::Library { file: f, link: Some(l), .. } => !joined && *f == file && *l == link,
_ => false,
};
self.conns.iter().filter(|(_, c)| uses(c)).map(|(k, _)| *k).collect()
};
let (people, libraries) = (with(true), with(false));
// People in the file are told the session is over for them, as when a host stops (they
// keep their copy; nothing retries); a library request is refused.
for conn in &people {
if let Some(c) = self.conns.get(conn) {
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into()));
}
self.gone(*conn);
}
self.kick(&libraries, "the link you joined with was revoked");
}
if let Some(file) = handled.removed {
let users: Vec<u64> = self

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) => {
@ -211,8 +246,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)),
ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)),
ServerMsg::Reported { error } => inbox.push(Event::Reported(error)),
// Answers for managing and library connections: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {}
// Answers for managing and library connections, and to a join code: not this one's.
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {}
}
Taken::Continue
}

View File

@ -19,6 +19,7 @@ pub mod inbox;
pub mod invite;
mod join;
pub mod limits;
mod sessions;
pub mod throttle;
#[cfg(not(target_arch = "wasm32"))]
@ -51,6 +52,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip};
#[cfg(target_arch = "wasm32")]
pub use web::NetLink;
#[cfg(test)]
mod code_tests;
#[cfg(test)]
mod driver_tests;
#[cfg(test)]
@ -62,4 +65,6 @@ mod loopback_tests;
#[cfg(test)]
mod session_tests;
#[cfg(test)]
mod sessions_tests;
#[cfg(test)]
mod tls_tests;

View File

@ -5,9 +5,11 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use figcraft_collab::code;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_engine::live::{
Bind, Event, HostInfo, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
SessionId, SessionInfo, Shared,
};
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
@ -16,6 +18,7 @@ use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::manage;
use crate::net::NetLimits;
use crate::sessions::Sessions;
use crate::token;
/// The port a hosted session tries first.
@ -29,12 +32,20 @@ enum Active {
Guest(Guest),
}
/// Hosts or joins one session at a time.
impl Active {
/// Stop hosting, or leave.
fn end(self) {
match self {
Active::Host(mut h) => h.stop(),
Active::Guest(mut g) => g.leave(),
}
}
}
/// Hosts and joins sessions, any number at once (one per open document).
pub struct NetLink {
wake: Waker,
limits: NetLimits,
active: Option<Active>,
inbox: Option<Inbox>,
sessions: Sessions<Active>,
/// Tweaks for joining (tests shorten the reconnect pauses).
guest: fn(GuestConfig) -> GuestConfig,
/// Answers to requests sent in the background (outlives sessions).
@ -47,7 +58,7 @@ impl NetLink {
/// `wake` is called (from network threads) whenever something arrives for the UI.
pub fn new(wake: Waker) -> Self {
let libraries = Inbox::new(wake.clone());
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c, libraries, fetching: Arc::default() }
Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default() }
}
/// With other transport limits.
@ -62,20 +73,27 @@ impl NetLink {
self
}
/// How this link joins `invite`.
fn guest_config(&self, invite: Invite, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
/// How this link joins `invite` (after asking its server about `code`, if there is one).
fn guest_config(&self, invite: Invite, code: Option<String>, name: &str) -> GuestConfig {
let mut cfg = (self.guest)(GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) });
cfg.limits = self.limits;
cfg
}
/// The running host, if hosting.
pub fn host_handle(&self) -> Option<&Host> {
match &self.active {
/// The running host of session `session`, if it is hosted here.
pub fn host_handle(&self, session: SessionId) -> Option<&Host> {
match self.sessions.get(session) {
Some(Active::Host(h)) => Some(h),
_ => None,
}
}
/// Session `session` started (one that had the id ends).
fn started(&mut self, session: SessionId, active: Active, inbox: Inbox) {
if let Some(old) = self.sessions.insert(session, active, inbox) {
old.end();
}
}
}
/// This machine's address on the local network: the interface the default route leaves by (no
@ -96,15 +114,20 @@ pub fn lan_ip() -> Option<IpAddr> {
}
impl LiveLink for NetLink {
fn host(&mut self, o: HostOptions) -> Result<SessionInfo, String> {
self.leave();
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
let ip = match o.bind {
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
Bind::Lan => lan_ip().ok_or("this machine has no local network address (connect to a network, or share on this machine only)")?,
Bind::Addr(ip) => ip,
};
let (file, token) = (token::file_id()?, token::secret()?);
let inbox = Inbox::new(self.wake.clone());
// A session shared with a join code: its secret is short enough to be typed with the
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
let (file, token) = if o.short {
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
} else {
(token::file_id()?, token::secret()?)
};
let inbox = self.sessions.inbox();
let doc = (*o.doc).clone();
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
let file_name = doc.name.clone();
@ -133,34 +156,33 @@ impl LiveLink for NetLink {
network: !ip.is_loopback(),
access: Access::Edit,
};
self.inbox = Some(inbox);
self.active = Some(Active::Host(host));
self.started(session, Active::Host(host), inbox);
Ok(info)
}
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
let cfg = self.guest_config(invite, name);
self.leave();
let inbox = Inbox::new(self.wake.clone());
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
self.join_resuming(session, link, name, None)
}
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, resume: Option<Resume>) -> Result<(), String> {
let (invite, code) = Invite::parse_join(link)?;
let cfg = GuestConfig { resume, ..self.guest_config(invite, code, name) };
let inbox = self.sessions.inbox();
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
self.started(session, Active::Guest(guest), inbox);
Ok(())
}
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
// Without a key: as anyone may, when the server is open to the public (it says if not).
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
self.leave();
let inbox = Inbox::new(self.wake.clone());
let job = Publish { server, key, name: o.name, doc: o.doc };
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
let inbox = self.sessions.inbox();
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.inbox = Some(inbox);
self.active = Some(Active::Guest(guest));
self.started(session, Active::Guest(guest), inbox);
Ok(())
}
@ -180,6 +202,10 @@ impl LiveLink for NetLink {
hosting(server, file, owner, req, &self.limits)
}
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
share(server, file, owner, revoke, create, &self.limits)
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
let (inbox, limits) = (self.libraries.clone(), self.limits);
let (failed, failure) = (inbox.clone(), req.failure());
@ -189,6 +215,7 @@ impl LiveLink for NetLink {
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)),
};
inbox.push(Event::Net { job, reply: Box::new(reply) });
});
@ -216,34 +243,31 @@ impl LiveLink for NetLink {
}
}
fn leave(&mut self) {
match self.active.take() {
Some(Active::Host(mut h)) => h.stop(),
Some(Active::Guest(mut g)) => g.leave(),
None => {}
fn leave(&mut self, session: SessionId) {
if let Some(active) = self.sessions.remove(session) {
active.end();
}
self.inbox = None;
}
fn presence(&mut self, p: &Presence) {
match &self.active {
fn presence(&mut self, session: SessionId, p: &Presence) {
match self.sessions.get(session) {
Some(Active::Host(h)) => h.presence(p.clone()),
Some(Active::Guest(g)) => g.presence(p.clone()),
None => {}
}
}
fn send(&mut self, msg: ClientMsg) {
match &self.active {
fn send(&mut self, session: SessionId, msg: ClientMsg) {
match self.sessions.get(session) {
Some(Active::Host(h)) => h.send(msg),
Some(Active::Guest(g)) => g.send(msg),
None => {}
}
}
fn poll(&mut self) -> Vec<Event> {
let mut out = self.libraries.drain();
out.extend(self.inbox.as_ref().map(Inbox::drain).unwrap_or_default());
fn poll(&mut self) -> Vec<(SessionId, Event)> {
let mut out: Vec<(SessionId, Event)> = self.libraries.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
out.extend(self.sessions.poll());
out
}
}
@ -271,6 +295,14 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &N
Ok((info, reply))
}
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
/// the file's owner.
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
let (endpoint, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
}
/// One request to the plugin directory at `server` (its key in the address's query, if any).
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
let (server, query) = Endpoint::parse(server)?;
@ -280,6 +312,8 @@ fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginRep
impl Drop for NetLink {
fn drop(&mut self) {
self.leave();
for id in self.sessions.ids() {
self.leave(id);
}
}
}

View File

@ -7,9 +7,10 @@
use std::time::Duration;
use figcraft_collab::message::{
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
PluginReq, ServerMsg,
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Resolve, ServerMsg,
};
use figcraft_engine::live::{Shared, SharedLink};
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
@ -80,6 +81,74 @@ pub fn hosting(
Ok((info, replies))
}
/// Ask the server at `server` which file join code `code` opens: the file's id and its link's
/// token. Fails with the server's own words when it knows no such code (or the address tried too
/// many: the error starts with [`REFUSED`]); a server older than join codes can't read the
/// question ("couldn't be read").
pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
let ask = Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::Resolve(ask))?;
let answer = reply(&mut ws, limits, |m| match m {
ServerMsg::Resolved { file, token } => Some((file, token)),
_ => None,
})?;
close(ws);
Ok(answer)
}
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
/// make a new edit link and a new view link and ask for their join codes: what others join with.
/// A server older than join codes makes the links without codes.
pub fn share(
server: &Endpoint,
key: Option<&str>,
file: &str,
owner: &str,
revoke: &[String],
create: bool,
limits: &NetLimits,
timeout: Duration,
) -> Result<Option<Shared>, String> {
let ask = |requests: Vec<Admin>| match key {
Some(key) => request(server, key, requests, limits, timeout),
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
};
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
let mut made = Vec::new();
for reply in ask(requests)? {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
};
// Their codes, on a connection of its own: a server from before join codes can't read the
// request and closes it (the links are shared without codes then).
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
.unwrap_or_default()
.into_iter()
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut shared = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = shared(edit);
Ok(Some(Shared { edit, view: Some(shared(view)) }))
}
/// Send one library request about the file behind share link `link`; the reply. Fails when the
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
/// [`LibraryReply::Failed`] reply). Never echoes the link.
@ -148,12 +217,15 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
}
/// How an error starts when the server said no (then its reason), rather than not answering.
pub(crate) const REFUSED: &str = "the server refused: ";
/// The next answer `pick` takes, or why there is none.
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
Ok(ServerMsg::Pong) => continue,
Ok(m) => pick(m).ok_or_else(|| "the server sent something other than an answer".into()),
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),

View File

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

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

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

@ -84,7 +84,7 @@ impl Client {
/// Start from the server's `Welcome` (which must carry a snapshot).
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, access, .. } = welcome else {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
return Err(Error::Protocol("expected a welcome with the document".into()));
};
let mut confirmed = Replica::from_snapshot(&snapshot)?;
@ -97,7 +97,9 @@ impl Client {
confirmed,
seq,
pending: VecDeque::new(),
next_batch: 1,
// After the batches the server already processed for this id (a client that starts
// over under the id it had, e.g. after its app crashed: lower ids would be ignored).
next_batch: processed.last_batch.saturating_add(1),
next_id,
peers: peers.into_iter().map(|p| (p.client_id, p)).collect(),
presence: BTreeMap::new(),
@ -451,7 +453,9 @@ impl Client {
}
// Cursor chat, spotlights and what a server says about its hosting are the app's (the
// transport hands them over).
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } => Ok(Vec::new()),
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } | ServerMsg::Resolved { .. } => {
Ok(Vec::new())
}
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
if session != self.session || client_id != self.id {

170
crates/collab/src/code.rs Normal file
View File

@ -0,0 +1,170 @@
//! Join codes: the short text someone types to join a shared file, in place of an invite link.
//!
//! Both kinds use Crockford's base32 alphabet (digits and capitals without `I`, `L`, `O`, `U`), so
//! a code survives being read aloud or copied by hand: reading one ignores case, spaces and dashes
//! and takes `O` as `0` and `I` or `L` as `1`.
//!
//! - An **online** code ([`ONLINE_LEN`] symbols, 45 random bits, shown `K7M-2QX-9TD`) is a name a
//! server gave one share link: the server is asked which file it opens.
//! - A **local network** code ([`LOCAL_LEN`] symbols, shown in fives) has no server to ask, so it
//! carries what a joiner needs ([`Local`]): the host's IPv4 address, its port, the session's
//! 44-bit secret and 8 check bits that catch typing mistakes before anything connects.
use std::net::Ipv4Addr;
/// Crockford's base32 symbols, by value.
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// Symbols in an online code.
pub const ONLINE_LEN: usize = 9;
/// Symbols in a local network code.
pub const LOCAL_LEN: usize = 20;
/// Bits of a local session's secret.
const SECRET_BITS: u32 = 44;
/// Longest text read as a code (anything longer isn't one).
const MAX_TYPED: usize = 128;
/// The file id every local session uses (its port tells sessions apart).
pub const LOCAL_FILE: &str = "local";
const TOKEN_PREFIX: &str = "local-";
/// The value of a typed symbol: its own, or the digit it is mistaken for.
fn value(c: char) -> Option<u8> {
let c = match c.to_ascii_uppercase() {
'O' => '0',
'I' | 'L' => '1',
c => c,
};
ALPHABET.iter().position(|a| char::from(*a) == c).and_then(|i| u8::try_from(i).ok())
}
/// The symbol for the low five bits of `v`.
fn symbol(v: u8) -> char {
char::from(ALPHABET.get(usize::from(v & 31)).copied().unwrap_or(b'0'))
}
/// The lowest `n` symbols of `v`, most significant first.
fn symbols(v: u128, n: usize) -> String {
(0..n).rev().map(|i| symbol((v >> (5 * i)).to_le_bytes()[0])).collect()
}
/// What someone typed, as a code's symbols: capitals, without spaces and dashes, look-alikes
/// read as the digits they stand for. Errors say what to fix (they never echo the text).
pub fn normalize(text: &str) -> Result<String, String> {
if text.len() > MAX_TYPED {
return Err("that is too long to be a join code".into());
}
let typed = text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '-' | '–' | '—' | '_' | '.'));
let code = typed
.map(|c| value(c).map(symbol))
.collect::<Option<String>>()
.ok_or("a join code has only letters and digits (and no U): check it and try again")?;
if code.is_empty() {
return Err("type the join code you were given".into());
}
Ok(code)
}
/// A code as it is shown: in groups, with dashes (`K7M-2QX-9TD`; local codes in fives).
pub fn grouped(code: &str) -> String {
let n = if code.len() == ONLINE_LEN { 3 } else { 5 };
let chars: Vec<char> = code.chars().collect();
chars.chunks(n).map(|c| c.iter().collect::<String>()).collect::<Vec<_>>().join("-")
}
/// An online code from 45 random bits (the rest of `random` is ignored).
pub fn online(random: u64) -> String {
symbols(u128::from(random), ONLINE_LEN)
}
/// What a local network code carries.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Local {
pub ip: Ipv4Addr,
pub port: u16,
/// The session's secret ([`SECRET_BITS`] bits).
pub secret: u64,
}
impl Local {
/// For a session at `ip:port` with a secret made from `random` (its low 44 bits).
pub fn new(ip: Ipv4Addr, port: u16, random: u64) -> Self {
Self { ip, port, secret: random & ((1 << SECRET_BITS) - 1) }
}
/// Address, port and secret as one number (92 bits).
fn bits(&self) -> u128 {
(u128::from(u32::from(self.ip)) << 60) | (u128::from(self.port) << SECRET_BITS) | u128::from(self.secret)
}
/// The code's symbols (show them [`grouped`]).
pub fn code(&self) -> String {
let bits = self.bits();
symbols((bits << 8) | u128::from(check(bits)), LOCAL_LEN)
}
/// Read a [`normalize`]d local code.
fn read(code: &str) -> Result<Local, String> {
const TYPO: &str = "that local network code has a typing mistake: check it and try again";
let v = code.chars().try_fold(0u128, |v, c| value(c).map(|s| (v << 5) | u128::from(s))).ok_or(TYPO)?;
let bits = v >> 8;
let local = Local {
ip: Ipv4Addr::from(u32::try_from(bits >> 60).map_err(|_| TYPO)?),
port: u16::try_from((bits >> SECRET_BITS) & 0xFFFF).map_err(|_| TYPO)?,
secret: u64::try_from(bits & ((1 << SECRET_BITS) - 1)).map_err(|_| TYPO)?,
};
if u128::from(check(bits)) != v & 0xFF || local.port == 0 || local.ip.is_unspecified() {
return Err(TYPO.into());
}
Ok(local)
}
/// The session's token: what its host checks a hello against.
pub fn token(&self) -> String {
format!("{TOKEN_PREFIX}{:011x}", self.secret)
}
/// The session's invite link (what a joiner's app connects with).
pub fn link(&self) -> String {
format!("figcraft://join?host={}:{}&file={LOCAL_FILE}&token={}", self.ip, self.port, self.token())
}
/// The session behind a local session's invite link (`None` for any other link: only IPv4
/// hosts with a code-sized secret have a code).
pub fn from_link(link: &str) -> Option<Local> {
let query = link.strip_prefix("figcraft://join?")?;
let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v));
let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?;
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?;
let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? };
(get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local)
}
}
/// Eight check bits over a local code's 92 bits (FNV-1a, folded): not a secret, only a typo catcher.
fn check(bits: u128) -> u8 {
let h = bits.to_be_bytes().iter().fold(0x811C_9DC5_u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(0x0100_0193));
h.to_be_bytes().iter().fold(0, |a, b| a ^ b)
}
/// A code someone typed, by kind.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Code {
/// A server's code (its symbols): the server says which file it opens.
Online(String),
Local(Local),
}
/// Read what someone typed as a join code of either kind.
pub fn parse(text: &str) -> Result<Code, String> {
let code = normalize(text)?;
match code.len() {
ONLINE_LEN => Ok(Code::Online(code)),
LOCAL_LEN => Local::read(&code).map(Code::Local),
n => Err(format!("a join code has {ONLINE_LEN} characters ({LOCAL_LEN} on the local network); that one has {n}")),
}
}
#[cfg(test)]
#[path = "code_tests.rs"]
mod tests;

View File

@ -0,0 +1,149 @@
//! Join codes: what typing tolerates, both kinds round trip, and tampered local codes are caught.
use std::net::Ipv4Addr;
use super::*;
#[test]
fn typed_codes_are_normalised() {
let table = [
("K7M-2QX-9TD", Ok("K7M2QX9TD")),
(" k7m 2qx 9td\n", Ok("K7M2QX9TD")),
("k7m2qx9td", Ok("K7M2QX9TD")),
("K7M–2QX—9TD", Ok("K7M2QX9TD")),
("K7M_2QX.9TD", Ok("K7M2QX9TD")),
// Look-alikes are the digits they are mistaken for.
("O0o-IiL-l1X", Ok("00011111X")),
("", Err("type the join code")),
(" - ", Err("type the join code")),
("K7M-2QU-9TD", Err("no U")),
("K7M/2QX", Err("only letters and digits")),
("K7M2QX9T€", Err("only letters and digits")),
("figcraft://join?host=1.2.3.4:5&file=f&token=t", Err("only letters and digits")),
];
for (typed, want) in table {
match (normalize(typed), want) {
(Ok(got), Ok(want)) => assert_eq!(got, want, "{typed:?}"),
(Err(e), Err(part)) => assert!(e.contains(part), "{typed:?}: {e}"),
(got, want) => panic!("{typed:?}: got {got:?}, wanted {want:?}"),
}
}
assert!(normalize(&"A".repeat(500)).is_err(), "too long to be a code");
}
#[test]
fn errors_never_echo_what_was_typed() {
for typed in ["s3cr3t!!", "SECRETSECRETSECRETSECRE", "figcraft://join?token=hunter2hunter2hunter2"] {
let e = parse(typed).unwrap_err();
assert!(!e.to_lowercase().contains(&typed.to_lowercase()[..6]), "{e}");
}
}
#[test]
fn online_codes_are_nine_unambiguous_symbols() {
for random in [0, 1, u64::MAX, 0x1234_5678_9ABC_DEF0] {
let code = online(random);
assert_eq!(code.len(), ONLINE_LEN);
assert!(code.chars().all(|c| c.is_ascii_digit() || (c.is_ascii_uppercase() && !"ILOU".contains(c))), "{code}");
assert_eq!(parse(&grouped(&code).to_lowercase()), Ok(Code::Online(code)));
}
assert_eq!(grouped("K7M2QX9TD"), "K7M-2QX-9TD");
assert_ne!(online(1), online(2));
// Only 45 bits count.
assert_eq!(online(1 << 45), online(0));
}
#[test]
fn local_codes_round_trip() {
for (ip, port, random) in [([192, 168, 1, 20], 47_900, 0x0ABC_DEF0_1234_u64), ([10, 0, 0, 1], 1, 0), ([127, 0, 0, 1], 65_535, u64::MAX)] {
let local = Local::new(Ipv4Addr::from(ip), port, random);
let code = local.code();
assert_eq!(code.len(), LOCAL_LEN);
let shown = grouped(&code);
assert_eq!(shown.len(), LOCAL_LEN + 3, "four groups of five: {shown}");
assert_eq!(parse(&shown), Ok(Code::Local(local)));
assert_eq!(parse(&shown.to_lowercase().replace('-', " ")), Ok(Code::Local(local)));
// The link a joiner connects with names the same session, and gives the code back.
let link = local.link();
assert_eq!(link, format!("figcraft://join?host={}:{port}&file=local&token={}", Ipv4Addr::from(ip), local.token()));
assert_eq!(local.token().len(), 17);
assert_eq!(Local::from_link(&link), Some(local));
}
}
#[test]
fn tampered_local_codes_are_caught() {
let code = Local::new(Ipv4Addr::new(192, 168, 1, 20), 47_900, 0x123_4567_89AB).code();
let symbols = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let (mut tried, mut missed) = (0, 0);
for at in 0..code.len() {
for s in symbols.chars().filter(|s| code.chars().nth(at) != Some(*s)) {
let mut changed: Vec<char> = code.chars().collect();
changed[at] = s;
tried += 1;
missed += usize::from(parse(&changed.iter().collect::<String>()).is_ok());
}
}
// Eight check bits: about one changed symbol in 256 slips through (and then fails to join).
assert!(missed * 50 < tried, "{missed} of {tried} single-symbol changes went unnoticed");
// Two symbols swapped.
let mut swapped: Vec<char> = code.chars().collect();
let at = (0..19).find(|i| swapped[*i] != swapped[*i + 1]).unwrap();
swapped.swap(at, at + 1);
assert!(parse(&swapped.iter().collect::<String>()).unwrap_err().contains("typing mistake"));
// Wrong lengths say what a code looks like.
assert!(parse(&code[..19]).unwrap_err().contains("9 characters"));
assert!(parse("K7M2QX9T").unwrap_err().contains("that one has 8"));
}
#[test]
fn only_local_session_links_have_a_code() {
for link in [
"figcraft://join?host=192.168.1.20:47900&file=abc&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:47900&file=local&token=AbCdEfGhIjKlMnOpQrStUv",
"figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az",
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
"",
] {
assert_eq!(Local::from_link(link), None, "{link}");
}
}
#[test]
fn the_new_messages_go_last_on_the_wire() {
use crate::message::{Admin, AdminReply, ClientMsg, Resolve, ServerMsg, encode};
// postcard writes a variant's index: an older peer reads everything it knew unchanged, and
// fails only on the variants added for join codes (which is how the app tells).
let tag = |bytes: Vec<u8>| bytes[1];
let ask = Resolve { protocol: 2, client_version: String::new(), code: "K7M2QX9TD".into() };
assert_eq!(tag(encode(&ClientMsg::Ping).unwrap()), 4);
assert_eq!(tag(encode(&ClientMsg::Report { reason: String::new() }).unwrap()), 13);
assert_eq!(tag(encode(&ClientMsg::Resolve(ask)).unwrap()), 14);
assert_eq!(tag(encode(&ServerMsg::Pong).unwrap()), 7);
assert_eq!(tag(encode(&ServerMsg::Reported { error: None }).unwrap()), 13);
assert_eq!(tag(encode(&ServerMsg::Resolved { file: String::new(), token: String::new() }).unwrap()), 14);
assert_eq!(tag(encode(&Admin::Blocked).unwrap()), 11);
assert_eq!(tag(encode(&Admin::Code { file: String::new(), token: String::new() }).unwrap()), 12);
assert_eq!(tag(encode(&AdminReply::Blocked(Vec::new())).unwrap()), 10);
assert_eq!(tag(encode(&AdminReply::Code { code: String::new() }).unwrap()), 11);
}
#[test]
fn junk_never_panics() {
let mut seed = 0x9E37_79B9_7F4A_7C15_u64;
for _ in 0..20_000 {
let len = (seed % 40) as usize;
let text: String = (0..len)
.map(|_| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
char::from_u32((seed >> 40) as u32 % 0x250).unwrap_or('-')
})
.collect();
let _ = parse(&text);
let _ = Local::from_link(&text);
let _ = grouped(&text);
}
}

View File

@ -109,7 +109,13 @@ fn kind_changes_and_transforms_round_trip() {
}
fn style(id: &str) -> Style {
Style { id: id.into(), name: format!("S {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] } }
Style {
id: id.into(),
name: format!("S {id}"),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] },
bound: Default::default(),
}
}
#[test]

View File

@ -10,7 +10,7 @@ use crate::sim::Sim;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
fn style(id: String, name: &str) -> Style {
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() }, bound: Default::default() }
}
fn comment(id: u64, doc: &Document) -> Comment {

View File

@ -23,6 +23,7 @@
pub mod change;
pub mod client;
pub mod code;
mod diff;
mod docprops;
pub mod frac;
@ -137,6 +138,8 @@ mod merge_list_tests;
#[cfg(test)]
mod merge_tests;
#[cfg(test)]
mod owned_tests;
#[cfg(test)]
mod reconnect_tests;
#[cfg(test)]
mod server_tests;

View File

@ -27,7 +27,13 @@ fn files() -> Files {
}
fn style(id: &str, c: Color) -> Style {
Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(c)] } }
Style {
id: id.into(),
name: format!("Style {id}"),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(c)] },
bound: Default::default(),
}
}
fn plan_of(f: &Files) -> Plan {

View File

@ -266,6 +266,28 @@ pub enum Admin {
Unblock { ip: String },
/// The blocked addresses.
Blocked,
/// A join code for the link of `file` whose token this is (the token proves the link and lets
/// the server give it to whoever types the code): [`AdminReply::Code`]. A link has one code:
/// asking again replaces it. Servers older than join codes can't read this request.
Code { file: String, token: String },
/// On an open hosting connection (no file named in its opening): what became of the files
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
Owned { files: Vec<(String, String)> },
}
/// The most files one [`Admin::Owned`] asks about.
pub const MAX_OWNED: usize = 200;
/// A file its owner asked about ([`Admin::Owned`]).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwnedFile {
/// Its name on the server.
pub name: String,
/// The ids of its share links (the owner's own and the ones made for others).
pub links: Vec<String>,
/// When it is deleted if nobody opens it (seconds since 1970).
pub expires: Option<u64>,
}
/// A file a server keeps.
@ -336,6 +358,13 @@ pub enum AdminReply {
Listing(Vec<FileRow>),
Reports(Vec<ReportInfo>),
Blocked(Vec<String>),
/// A link's new join code (shown this once; the server keeps only a hash).
Code {
code: String,
},
/// The answer to [`Admin::Owned`], one per pair asked about, in order. `None`: no file of that
/// id is managed by that token here (removed, expired, or never there).
Owned(Vec<Option<OwnedFile>>),
}
/// A file with what its operator moderates by.
@ -380,6 +409,17 @@ pub struct OpenHosting {
pub owner: String,
}
/// Asks a server which file a join code opens (the first message on a connection, instead of
/// [`Hello`]). Answered with [`ServerMsg::Resolved`], then the connection closes; a wrong code, too
/// many wrong ones from an address, or a server that hosts no files is `Refused`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Resolve {
pub protocol: u32,
pub client_version: String,
/// The code's symbols ([`crate::code::normalize`]d).
pub code: String,
}
/// What a server says about its hosting: whether anyone may publish, who runs it and its terms,
/// how long files published without the key are kept unopened, and the largest file.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -580,6 +620,8 @@ pub enum ClientMsg {
Report {
reason: String,
},
/// Ask which file a join code opens (the first message on a connection, instead of `Hello`).
Resolve(Resolve),
}
/// Server → client.
@ -642,6 +684,12 @@ pub enum ServerMsg {
Reported {
error: Option<String>,
},
/// The answer to [`ClientMsg::Resolve`]: the file the code opens and its link's token (what a
/// `Hello` joins with).
Resolved {
file: String,
token: String,
},
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these

View File

@ -0,0 +1,53 @@
//! `Admin::Owned` and its answer on the wire: both encodings round-trip, the new variants sit at
//! the end of their enums (so every message an older peer reads keeps its bytes), and an older
//! peer, which doesn't know them, fails to read them instead of misreading them.
use crate::message::{Admin, AdminReply, ClientMsg, OwnedFile, ServerMsg, decode, decode_json, encode, encode_json};
fn asked() -> ClientMsg {
ClientMsg::Admin(Admin::Owned { files: vec![("f1".into(), "OwnerToken".into()), (String::new(), String::new())] })
}
fn answered() -> ServerMsg {
let file = OwnedFile { name: "Homepage".into(), links: vec!["a".into(), "b".into()], expires: Some(1_900_000_000) };
ServerMsg::Admin(Box::new(AdminReply::Owned(vec![Some(file), None])))
}
#[test]
fn both_encodings_round_trip() {
let (c, s) = (asked(), answered());
assert_eq!(decode::<ClientMsg>(&encode(&c).unwrap(), 1 << 20).unwrap(), c);
assert_eq!(decode::<ServerMsg>(&encode(&s).unwrap(), 1 << 20).unwrap(), s);
let json = encode_json(&c).unwrap();
assert!(json.contains(r#""owned":{"files":[["f1","OwnerToken"],["",""]]}"#), "{json}");
assert_eq!(decode_json::<ClientMsg>(&json, 1 << 20).unwrap(), c);
let json = encode_json(&s).unwrap();
assert!(json.contains(r#""owned":[{"name":"Homepage","links":["a","b"],"expires":1900000000},null]"#), "{json}");
assert_eq!(decode_json::<ServerMsg>(&json, 1 << 20).unwrap(), s);
}
#[test]
fn the_new_variants_are_the_last_of_their_enums() {
// Postcard writes a variant as its index: the last byte before the payload of an `Admin`
// request is the variant's. `Code` was the last one before; `Owned` follows it.
let index = |a: Admin| postcard::to_allocvec(&a).unwrap()[0];
let code = index(Admin::Code { file: String::new(), token: String::new() });
assert_eq!(index(Admin::Owned { files: vec![] }), code + 1);
let index = |r: AdminReply| postcard::to_allocvec(&r).unwrap()[0];
assert_eq!(index(AdminReply::Owned(vec![])), index(AdminReply::Code { code: String::new() }) + 1);
// A peer from before them reads an unknown variant as an error, never as another message:
// one index further is as unknown to this version as `Owned` is to an older one.
let mut bytes = encode(&asked()).unwrap();
let at = bytes.iter().position(|b| *b == code + 1).unwrap();
bytes[at] = code + 2;
assert!(decode::<ClientMsg>(&bytes, 1 << 20).is_err());
}
#[test]
fn hostile_answers_are_refused_by_size_not_trusted() {
// A count that promises more than the message holds doesn't allocate or read past the end.
let mut bytes = encode(&answered()).unwrap();
bytes.truncate(bytes.len() - 3);
assert!(decode::<ServerMsg>(&bytes, 1 << 20).is_err());
assert!(decode::<ServerMsg>(&encode(&answered()).unwrap(), 4).is_err(), "over the size limit");
}

View File

@ -392,6 +392,7 @@ impl Server {
| ClientMsg::OpenPlugins(_)
| ClientMsg::Plugins(_)
| ClientMsg::OpenHosting(_)
| ClientMsg::Resolve(_)
| ClientMsg::Report { .. } => {
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
}

View File

@ -109,7 +109,13 @@ pub(crate) fn random_edit(doc: &mut Document, rng: &mut Rng, derive: &dyn Recomp
doc.styles.remove(i);
}
Some(i) => doc.styles[i].name = format!("Renamed {}", rng.below(100)),
None => doc.styles.push(Style { id, name: "S".into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } }),
None => doc.styles.push(Style {
id,
name: "S".into(),
description: String::new(),
kind: StyleKind::Paint { paints: vec![] },
bound: Default::default(),
}),
}
}
}

View File

@ -77,6 +77,8 @@ impl Color {
pub const DEFAULT_SHAPE: Color = Color::from_rgb8_const(0xD9, 0xD9, 0xD9);
/// Default page background (#F5F5F5).
pub const DEFAULT_PAGE: Color = Color::from_rgb8_const(0xF5, 0xF5, 0xF5);
/// The background of a page made while the interface shows its dark theme (#1E1E1E).
pub const DARK_PAGE: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E);
pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r: clamp01(r), g: clamp01(g), b: clamp01(b), a: clamp01(a) }
@ -199,6 +201,11 @@ impl Color {
0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b)
}
/// Whether black on this colour reads worse than white (a dark page: new text is white).
pub fn is_dark(self) -> bool {
self.contrast(Color::WHITE) > self.contrast(Color::BLACK)
}
/// WCAG contrast ratio between two opaque colours.
pub fn contrast(self, other: Color) -> f32 {
let (a, b) = (self.luminance() + 0.05, other.luminance() + 0.05);

View File

@ -22,10 +22,17 @@ fn colourful() -> (Document, NodeId) {
n.grids = vec![LayoutGrid { color: RED, ..LayoutGrid::default() }];
n.interactions = vec![proto::Interaction {
trigger: Default::default(),
actions: vec![proto::Action::OpenOverlay { dest: p, position: Default::default(), close_on_outside: false, background: Some(RED) }],
actions: vec![proto::Action::OpenOverlay {
dest: p,
position: Default::default(),
close_on_outside: false,
background: Some(RED),
offset: None,
}],
transition: Default::default(),
duration_ms: 300,
easing: Default::default(),
delay_ms: 0,
}];
}
if let Some(NodeKind::Text(td)) = d.node_mut(t).map(|n| &mut n.kind) {
@ -40,6 +47,7 @@ fn colourful() -> (Document, NodeId) {
name: "Red".into(),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(RED)] },
bound: Default::default(),
});
d.variables.collections.push(Collection {
id: "c1".into(),

View File

@ -106,7 +106,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
/// boolean groups are hit where their `result` is.
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
let p = doc.node(page)?;
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, false).filter(|_| !masked_out(doc, p, *c, pt)))
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt)))
}
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
@ -129,24 +129,42 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool
/// Like [`hit_leaf`], but only within layer `root` (it included).
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
hit_rec(doc, root, pt, tol, 0, result, false)
hit_rec(doc, root, pt, tol, 0, result, None)
}
/// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit
/// where their outlines are, so a click inside one reaches the layers drawn behind it.
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
///
/// `also` is what outline mode draws besides the shapes: hidden layers are hit like the others
/// when it shows them, and every layer on the edge of its box when it shows object bounds.
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option<NodeId> {
match doc.node(root) {
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, true)),
_ => hit_rec(doc, root, pt, tol, 0, result, true),
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))),
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also)),
}
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, outline: bool) -> Option<NodeId> {
/// What outline mode shows besides the shapes' outlines (see [`hit_outlines`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct OutlineHits {
pub hidden: bool,
pub bounds: bool,
}
/// Whether `pt` is within `tol` of the edge of `n`'s box.
fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> bool {
let lp = doc.world_transform(id).inverse() * pt;
let r = n.local_rect();
r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp))
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option<OutlineHits>) -> Option<NodeId> {
if depth > 512 {
return None;
}
let n = doc.node(id)?;
if !n.visible || n.locked {
let outline = also.is_some();
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked {
return None;
}
if let NodeKind::Section { contents_hidden: true } = n.kind {
@ -164,11 +182,12 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
return None;
}
for c in n.paint_order().rev() {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, outline).filter(|_| !masked_out(doc, n, c, pt)) {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) {
return Some(h);
}
}
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) {
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol, outline) || also.is_some_and(|a| a.bounds) && on_box_edge(doc, id, n, pt, tol)
{
return Some(id);
}
None
@ -191,7 +210,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
let at = out.len();
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
if whole {
if hit_rec(doc, id, pt, tol, depth, result, false).is_some() {
if hit_rec(doc, id, pt, tol, depth, result, None).is_some() {
out.push((id, depth));
}
return;

View File

@ -7,7 +7,13 @@ use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Styl
const C3: u64 = 3 << 32;
fn style(id: &str) -> Style {
Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
Style {
id: id.into(),
name: format!("Style {id}"),
description: String::new(),
kind: StyleKind::Paint { paints: Vec::new() },
bound: Default::default(),
}
}
fn comment(id: u64, page: NodeId) -> Comment {

View File

@ -939,7 +939,11 @@ impl Document {
if n.id != *id {
issues.push(format!("node key {id} has id {}", n.id));
}
let mut seen = std::collections::BTreeSet::new();
for c in &n.children {
if !seen.insert(*c) {
issues.push(format!("child {c} listed twice in {id}"));
}
match self.node(*c) {
Some(cn) if cn.parent == Some(*id) => {}
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
@ -956,10 +960,32 @@ impl Document {
if n.is_page() != listed {
issues.push(format!("page list mismatch for {id}"));
}
if n.is_page() && n.parent.is_some() {
issues.push(format!("page {id} has a parent"));
}
}
let reachable = self.reachable();
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
issues.push(format!("{id} is not under a page (cycle or detached)"));
}
issues
}
/// Every node reachable from a page through reciprocal parent/child links, each once (cycles
/// and repeated children are walked once, so this is linear in the node count).
fn reachable(&self) -> std::collections::BTreeSet<NodeId> {
let mut seen = std::collections::BTreeSet::new();
let mut stack: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| n.is_page() && n.parent.is_none()).map(|(k, _)| *k).collect();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(n) = self.nodes.get(&id) else { continue };
stack.extend(n.children.iter().filter(|c| !seen.contains(*c) && self.nodes.get(*c).is_some_and(|cn| cn.parent == Some(id))));
}
seen
}
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
pub fn repair(&mut self) {
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
@ -967,34 +993,30 @@ impl Document {
if let Some(n) = self.nodes.get_mut(id) {
let n = Arc::make_mut(n);
n.id = *id;
// Pages are roots.
if n.is_page() {
n.parent = None;
}
}
}
// Drop dangling children and children claimed by another parent.
// Drop dangling children, children claimed by another parent and repeats.
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
for id in &ids {
let Some(n) = self.nodes.get(id) else { continue };
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
let mut seen = std::collections::BTreeSet::new();
let keep: Vec<NodeId> =
n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id) && seen.insert(*c)).collect();
if keep != n.children
&& let Some(n) = self.nodes.get_mut(id)
{
Arc::make_mut(n).children = keep;
}
}
// Orphans (parent missing or not listing them) are removed.
loop {
let orphan = self.nodes.iter().find_map(|(k, n)| {
if n.is_page() {
return None;
}
match n.parent {
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
_ => Some(*k),
}
});
let Some(o) = orphan else { break };
for d in self.subtree(o) {
self.nodes.remove(&d);
}
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
let reachable = self.reachable();
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
for k in gone {
self.nodes.remove(&k);
}
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
// don't start with their layer, are dropped.

View File

@ -1015,6 +1015,19 @@ pub enum CrossAlign {
Baseline,
}
/// How the lines of a wrapping layout (rows of a horizontal one, columns of a vertical one) share
/// the free space across the flow.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ContentAlign {
/// Packed, `cross_gap` apart, at the start, centre or end the cross alignment names (lines of
/// nothing but fill children stretch instead).
#[default]
Auto,
/// The free space goes between the lines (one line, or no free space: like `Auto`).
SpaceBetween,
}
/// Auto layout container properties (a frame with auto layout).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@ -1029,6 +1042,8 @@ pub struct AutoLayout {
pub padding: [f64; 4],
pub main_align: MainAlign,
pub cross_align: CrossAlign,
/// Wrapping layouts: how the lines are spread across the flow.
pub content_align: ContentAlign,
/// Grid mode: column and row counts (rows grow to fit the children).
pub grid_columns: u32,
pub grid_rows: u32,
@ -1052,6 +1067,7 @@ impl Default for AutoLayout {
padding: [10.0; 4],
main_align: MainAlign::Min,
cross_align: CrossAlign::Min,
content_align: ContentAlign::Auto,
grid_columns: 2,
grid_rows: 2,
column_tracks: Vec::new(),

View File

@ -534,6 +534,10 @@ pub enum Action {
/// Colour drawn behind the overlay (over the frame underneath); None = no background.
#[serde(default)]
background: Option<figcraft_color::Color>,
/// Manual position: where the overlay's top-left sits from the top-left of the layer that
/// opened it (x, y). None = right below that layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
offset: Option<[f64; 2]>,
},
SwapOverlay {
dest: NodeId,
@ -709,12 +713,33 @@ pub struct Interaction {
pub duration_ms: u32,
#[serde(default)]
pub easing: Easing,
/// Mouse enter, leave, down and up: how long after the gesture the actions run (ms).
#[serde(default, alias = "delayMs", skip_serializing_if = "is_zero")]
pub delay_ms: u32,
}
impl Interaction {
/// Seconds between the gesture and the actions: the delay of a mouse enter, leave, down or up
/// trigger (other triggers have none; "after delay" times itself).
pub fn delay_secs(&self) -> f64 {
match self.trigger {
Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => f64::from(self.delay_ms.min(MAX_DELAY_MS)) / 1000.0,
_ => 0.0,
}
}
}
/// Longest delay a trigger waits (ms).
pub const MAX_DELAY_MS: u32 = 600_000;
fn default_duration() -> u32 {
300
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
fn is_false(b: &bool) -> bool {
!*b
}

View File

@ -40,6 +40,12 @@ pub struct Style {
pub description: String,
#[serde(flatten)]
pub kind: StyleKind,
/// Variables bound to fields of the value, by the keys a layer's bindings use
/// (`fills/0/color`, `effects/0/radius`, `layoutGrids/0/size`, a text style's `fontSize`…).
/// A layer using the style sees them resolved in its own modes; `kind` holds them resolved in
/// the default modes.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bound: BTreeMap<String, VariableId>,
}
/// A node's links to shared styles.

View File

@ -97,6 +97,7 @@ fn duplicate_remaps_ids_and_interactions() {
transition: proto::Transition::Instant,
duration_ms: 300,
easing: proto::Easing::EaseOut,
delay_ms: 0,
});
let f2 = d.duplicate_into(f, p, None).unwrap();
let kids = d.node(f2).unwrap().children.clone();
@ -280,6 +281,30 @@ fn serde_roundtrip_and_repair() {
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
}
#[test]
fn repair_drops_cycles_and_repeated_children() {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
let mut v = serde_json::to_value(&d).unwrap();
// F and G each other's parent and child (G listing F twice), and R listed twice by the page.
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]);
v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r));
let mut bad: Document = serde_json::from_value(v).unwrap();
let issues = bad.validate();
for want in ["listed twice", "not under a page"] {
assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}");
}
bad.repair();
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
assert!(bad.node(f).is_none() && bad.node(g).is_none());
assert_eq!(bad.node(p).unwrap().children, vec![r]);
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn auto_layout_loads_files_without_grid_tracks() {
// Saved before grid tracks, cells and spans existed.

View File

@ -0,0 +1,115 @@
//! Wrapped-line alignment through commands: the `contentAlign` prop and its alias, undo, the CSS
//! and inspect output, instance overrides, the REST and `.fig` mappings, and junk values.
use serde_json::{Value, json};
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::{ContentAlign, NodeId};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// A 100 × 100 wrapping frame of three 40 × 20 rectangles (gap 10, row gap 5): two rows.
fn wrapping(s: &mut Session) -> (NodeId, Vec<NodeId>) {
let props = json!({"layout": "horizontal", "wrap": true, "padding": 0, "gap": 10, "crossGap": 5});
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": props})).unwrap());
let kids = (0..3).map(|_| id(s.execute("shape.rectangle", &json!({"parent": f, "x": 0, "y": 0, "width": 40, "height": 20})).unwrap())).collect();
(f, kids)
}
fn ys(s: &Session, kids: &[NodeId]) -> Vec<f64> {
kids.iter().map(|k| s.doc().unwrap().node(*k).unwrap().transform.as_coeffs()[5]).collect()
}
fn content(s: &Session, f: NodeId) -> ContentAlign {
s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().content_align
}
#[test]
fn content_align_spreads_the_rows_and_undoes() {
let mut s = Session::new();
let (f, kids) = wrapping(&mut s);
assert_eq!(ys(&s, &kids), [0.0, 0.0, 25.0]);
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
assert_eq!(ys(&s, &kids), [0.0, 0.0, 80.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((content(&s, f), ys(&s, &kids)), (ContentAlign::Auto, vec![0.0, 0.0, 25.0]));
// The file format's name and spelling work too; packed rows follow the cross alignment.
s.execute("node.set", &json!({"id": f, "props": {"counterAxisAlignContent": "SPACE_BETWEEN"}})).unwrap();
assert_eq!(content(&s, f), ContentAlign::SpaceBetween);
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "auto", "crossAlign": "max"}})).unwrap();
assert_eq!(ys(&s, &kids), [55.0, 55.0, 80.0]);
}
#[test]
fn css_and_inspect_name_the_lines() {
let mut s = Session::new();
let (f, _) = wrapping(&mut s);
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": f})).unwrap().to_string();
assert!(css(&mut s).contains("align-content: flex-start;"), "{}", css(&mut s));
s.execute("node.set", &json!({"id": f, "props": {"crossAlign": "center"}})).unwrap();
assert!(css(&mut s).contains("align-content: center;"));
s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap();
assert!(css(&mut s).contains("align-content: space-between;"));
let rows = s.execute("inspect.properties", &json!({"id": f})).unwrap().to_string();
assert!(rows.contains("Auto (space between)"), "{rows}");
// One line has no lines to align.
s.execute("node.set", &json!({"id": f, "props": {"wrap": false}})).unwrap();
assert!(!css(&mut s).contains("align-content"));
}
#[test]
fn an_instance_keeps_its_own_line_alignment() {
let mut s = Session::new();
let (f, _) = wrapping(&mut s);
let comp = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
let inst = id(s.execute("instance.create", &json!({"component": comp, "x": 200, "y": 0})).unwrap());
s.execute("node.set", &json!({"id": inst, "props": {"contentAlign": "spaceBetween"}})).unwrap();
// A change to the main reaches the instance; its own alignment stays.
s.execute("node.set", &json!({"id": comp, "props": {"padding": 2}})).unwrap();
assert_eq!((content(&s, comp), content(&s, inst)), (ContentAlign::Auto, ContentAlign::SpaceBetween));
}
#[test]
fn rest_and_fig_carry_the_setting() {
let frame = |extra: Value| {
let mut f = json!({"id": "1:2", "name": "Wrap", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 100, "y": 100},
"layoutMode": "HORIZONTAL", "layoutWrap": "WRAP", "children": []});
f.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
json!({"name": "File", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [f]}]}})
};
let read = |file: &Value| {
let (doc, _) = figcraft_format::rest::import(file, &figcraft_format::rest::no_images).unwrap();
doc
};
let of = |doc: &figcraft_doc::Document| doc.nodes().find(|n| n.name == "Wrap").unwrap().layout.as_ref().unwrap().content_align;
assert_eq!(of(&read(&frame(json!({})))), ContentAlign::Auto);
assert_eq!(of(&read(&frame(json!({"counterAxisAlignContent": "AUTO"})))), ContentAlign::Auto);
let spread = read(&frame(json!({"counterAxisAlignContent": "SPACE_BETWEEN"})));
assert_eq!(of(&spread), ContentAlign::SpaceBetween);
// Written to `.fig` and read back, both values survive.
for doc in [spread, read(&frame(json!({})))] {
let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
assert_eq!(of(&back), of(&doc));
}
}
#[test]
fn junk_values_are_refused_not_fatal() {
let junk =
[json!(null), json!(true), json!(-1), json!(1e308), json!(""), json!("sideways"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))];
for k in ["contentAlign", "counterAxisAlignContent"] {
for j in &junk {
let mut s = Session::new();
let (f, kids) = wrapping(&mut s);
for target in [f, kids[0]] {
let r = s.execute("node.set", &json!({"id": target, "props": {k: j}}));
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{k} = {j}: {r:?}");
}
assert_eq!(content(&s, f), ContentAlign::Auto, "{k} = {j}");
}
}
}

View File

@ -32,17 +32,17 @@ struct Wire {
struct Link(Arc<Mutex<Wire>>);
impl LiveLink for Link {
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
Err("no".into())
}
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
Err("no".into())
}
fn leave(&mut self) {}
fn presence(&mut self, _: &Presence) {}
fn send(&mut self, _: ClientMsg) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.0.lock().unwrap().answers)
fn leave(&mut self, _: u64) {}
fn presence(&mut self, _: u64, _: &Presence) {}
fn send(&mut self, _: u64, _: ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, Event)> {
std::mem::take(&mut self.0.lock().unwrap().answers).into_iter().map(|e| (0, e)).collect()
}
fn plugins(&mut self, _: &str, req: PluginReq) -> Result<PluginReply, String> {
self.0.lock().unwrap().waited += 1;
@ -112,6 +112,7 @@ fn answer(s: &mut Session, wire: &Arc<Mutex<Wire>>) -> usize {
NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))),
NetRequest::Upload(_) => NetReply::Upload(Err("no".into())),
NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())),
NetRequest::Share { .. } => NetReply::Share(Err("no".into())),
};
w.answers.push(Event::Net { job: *job, reply: Box::new(reply) });
}

View File

@ -0,0 +1,63 @@
use serde_json::json;
use crate::plugins_tests::{OnRefusal, install, manifest, script};
use crate::{EngineError, Session};
fn id(value: &serde_json::Value) -> crate::NodeId {
crate::params::id_of(&value["id"]).unwrap()
}
#[test]
fn failed_group_closes_documents_opened_inside_it() {
let mut s = Session::new();
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let before_journal = s.journal.clone();
let before_history = s.ds().unwrap().history.undo.len();
let result = s.execute(
"command.batch",
&json!({
"label": "Open then fail",
"commands": [
{"command": "file.new", "params": {"name": "Temporary"}},
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.docs.len(), 1);
assert_eq!(s.active, 0);
assert!(s.doc().unwrap().contains(original));
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
assert_eq!(s.journal, before_journal);
}
#[test]
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let before_history: Vec<_> = s.docs.iter().map(|ds| ds.history.undo.len()).collect();
let calls = [
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})),
("doc.nextTab", json!({})),
("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})),
("node.set", json!({"id": 999999, "props": {"name": "must fail"}})),
];
install(&mut s, manifest("org.test.rollback", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
s.message = Some("keep this".into());
let before_journal = s.journal.clone();
let before_message = s.message.clone();
// The new file is the one in front when the plugin starts.
let before_active = s.active;
let result = s.execute("plugin.run", &json!({"id": "org.test.rollback"}));
assert!(matches!(result, Err(EngineError::Other(_))));
assert_eq!(s.docs.iter().map(|ds| ds.doc.node(ds.page).map_or(0, |page| page.children.len())).collect::<Vec<_>>(), vec![0, 0]);
assert_eq!(s.active, before_active, "the tab in front is put back");
assert_eq!(s.docs.iter().map(|ds| ds.history.undo.len()).collect::<Vec<_>>(), before_history);
assert_eq!(s.journal, before_journal);
assert_eq!(s.message, before_message);
}

View File

@ -21,6 +21,9 @@ pub const SEP: &str = "-";
pub static MENU: &[MenuItem] = &[
// File
m!("File", "New design file", "file.new"),
// FigCraft's own: starter templates (not counted by `parity`, like the other items of its own
// listed in `OWN`).
m!("File", "New from template…", "file.newFromTemplate"),
m!("File", "New window", "window.new"),
m!("File", "Open…", "file.open"),
m!("File", "Import from file…", "file.import"),
@ -91,6 +94,9 @@ pub static MENU: &[MenuItem] = &[
m!("View", SEP, ""),
m!("View/Outlines", "Show outlines", "view.outlines"),
m!("View/Outlines", "Show outlines for selection", "view.outlinesSelection"),
m!("View/Outlines", SEP, ""),
m!("View/Outlines", "Include hidden layers", "view.outlinesHiddenLayers"),
m!("View/Outlines", "Include object bounds", "view.outlinesObjectBounds"),
m!("View", "Pixel preview", "view.pixelPreview"),
m!("View", "Mask outlines", "view.maskOutlines"),
m!("View", "Frame outlines", "view.frameOutlines"),
@ -98,6 +104,9 @@ pub static MENU: &[MenuItem] = &[
m!("View", "Soft-proof sRGB", "view.proofSrgb"),
m!("View", SEP, ""),
m!("View/Panels", "Show/Hide UI", "view.toggleUi"),
// FigCraft's own: the window fills the screen (F11), for setups that hide the taskbar then.
m!("View", "Full screen", "view.fullscreen"),
m!("View/Panels", "Reset panel widths", "view.panelWidths"),
m!("View/Panels", "Layers", "view.layers"),
m!("View/Panels", "Assets", "view.assets"),
m!("View/Panels", "Design", "view.design"),
@ -245,7 +254,9 @@ pub static MENU: &[MenuItem] = &[
// Libraries
m!("Libraries", "Open libraries", "libraries.open"),
m!("Libraries", "Publish library…", "libraries.publish"),
m!("Libraries", "Hide when publishing", "component.hideWhenPublishing"),
// Help
m!("Help", "Getting started", "help.gettingStarted"),
m!("Help", "Keyboard shortcuts", "help.shortcuts"),
m!("Help", "Help page", "help.page"),
m!("Help", "About FigCraft", "help.about"),
@ -275,12 +286,15 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
MENU.iter().filter(|m| m.label != SEP)
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
let mut done = 0;
let mut missing = Vec::new();
let mut total = 0;
for m in items().filter(|m| !m.id.starts_with("collab.")) {
for m in items().filter(|m| !m.id.starts_with("collab.") && !OWN.contains(&m.id)) {
total += 1;
if crate::find_command(m.id).is_some() || extra.contains(&m.id) {
done += 1;

View File

@ -11,6 +11,32 @@ use crate::components::*;
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
/// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and
/// sets (those `ids` are or are in), put on or taken off.
fn hide_when_publishing(s: &mut Session, p: &Value) -> Result<Value> {
use crate::library::{asset_root, hidden_name, with_hidden_name};
let cmd = "component.hideWhenPublishing";
let doc = s.doc()?;
let mut ids: Vec<NodeId> = Vec::new();
for i in req_targets(cmd, s, p)? {
let root = asset_root(doc, i).ok_or_else(|| bad(cmd, format!("{i} isn't a component, a component set or a layer in one")))?;
if doc.is_remote(root) {
return Err(EngineError::Disabled(cmd.into(), crate::library::READ_ONLY.into()));
}
if !ids.contains(&root) {
ids.push(root);
}
}
let hidden = |doc: &Document, i: &NodeId| doc.node(*i).is_some_and(|n| hidden_name(&n.name));
let on = boolean(p, "on").unwrap_or_else(|| !ids.iter().all(|i| hidden(doc, i)));
let doc = s.doc_mut()?;
for i in &ids {
let n = doc.get_mut(*i)?;
n.name = with_hidden_name(&n.name, on);
}
Ok(json!({"ids": ids, "hidden": on}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
@ -121,6 +147,13 @@ pub fn specs() -> Vec<CommandSpec> {
true,
reset_slot
),
spec!(
"component.hideWhenPublishing",
"Hide when publishing",
"{ids? (components, sets or layers in them; default the selection), on? (default: toggle, hiding unless all are hidden)} keeps them out of the library this file publishes, the way a name starting with `.` or `_` does: hiding puts a `.` before the name, showing takes the leading `.` and `_` away → {ids, hidden}",
true,
hide_when_publishing
),
spec!("component.detach", "Detach instance", "{ids?}", true, detach),
spec!("component.resetInstance", "Reset all changes", "{ids?}", true, reset_instance),
spec!(
@ -1350,7 +1383,7 @@ fn def_json(name: &str, d: &PropDef) -> Value {
}
/// Properties of a component, set or instance as JSON (`component.properties`).
fn describe_props(doc: &Document, id: NodeId) -> Value {
pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let (owner_comp, values) = match &n.kind {
NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())),

View File

@ -234,10 +234,17 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
base.to_string()
}
});
// In the app (it has a theme), text that names no fill is readable on its page: white on a
// dark one. Headless sessions make the same thing everywhere.
let asks_fill = get(p, "props").is_some_and(|o| ["fill", "fills", "fillStyle"].iter().any(|k| o.get(k).is_some()));
let app_text = s.ui_dark.is_some() && base == "Text" && !asks_fill;
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let nid = doc.alloc_id();
let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) };
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, doc.page_of(parent).unwrap_or(page)) {
node.fills = fills;
}
node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
if base == "Arrow" {
node.stroke.cap_end = StrokeCap::LineArrow;

View File

@ -340,7 +340,9 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(Value::Array(s.batch(&label, &cmds)?))
}),
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
let Some((id, p)) = s.last_command.clone() else { return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into())) };
let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else {
return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into()));
};
let spec = crate::find_command(&id).ok_or_else(|| EngineError::UnknownCommand(id.clone()))?;
(spec.run)(s, &p)
}),

View File

@ -15,7 +15,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("file.new", "New design file", "{name?}", false, |s, p| {
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
s.docs.push(DocState::new(Document::new(name), None));
let mut doc = Document::new(name);
// A property of the page from here on: a later change of theme leaves it alone.
let background = s.new_page_background();
for page in doc.pages.clone() {
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
*b = background;
}
}
s.docs.push(DocState::new(doc, None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
@ -79,9 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
false,
|s, p| {
let path = req_string("file.recover", p, "path")?;
let loaded =
read(&path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved());
s.docs.push(recovered(&path)?);
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}
@ -106,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
spec!("file.revert", "Revert to saved", "{}", false, |s, _| {
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
@ -406,6 +412,12 @@ fn restore_version(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// The recovery copy at `path` as a document with unsaved changes and no path.
pub(crate) fn recovered(path: &str) -> Result<DocState> {
let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
}
#[cfg(not(target_arch = "wasm32"))]
fn read(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
@ -516,6 +528,7 @@ fn load_with(
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
crate::style_vars::after_load(doc);
let mut insts: Vec<(usize, NodeId)> =
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
@ -649,6 +662,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
let warnings = rest || ext(&path) == "svg";
// A native file that lost something (a damaged image, say): the app says so, not only the result.
if native && !loaded.issues.is_empty() {
s.message = Some(match loaded.issues.as_slice() {
[one] => format!("{}: {one}", file_stem(&path)),
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
});
}
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active});
@ -769,8 +789,11 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
if ids.is_empty() {
return Err(nothing());
}
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
let drawn = drawn_layers(doc, ids, setting);
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) {
figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?;
}
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
// The layers' own area unless an option or a slice says otherwise.
let own_area = setting.contents_only
&& !setting.include_bounding_box
@ -784,7 +807,10 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
simplify_stroke: setting.svg_simplify_stroke,
bounds: Some(area),
};
figcraft_svg::export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).ok_or_else(nothing)
figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).map_err(|e| match e {
figcraft_svg::SvgError::Empty => nothing(),
other => EngineError::Other(other.to_string()),
})
}
ExportFormat::Pdf => {
let doc = &*doc.in_srgb();
@ -864,6 +890,11 @@ pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
/// The scale that gives `ids` (their export area together) `setting`'s export size.
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
// Size-based exports ask for bounds before encoding. Let encoding report the depth error
// without entering a recursive bounds walk here; SVG/PDF do not use this raster scale.
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) && figcraft_render::check_export_depth(doc, ids).is_err() {
return 1.0;
}
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
}

View File

@ -360,7 +360,7 @@ fn publish_to_server(s: &mut Session, p: &Value) -> Result<Value> {
let link = match server {
// The server's address with its key: the file is kept there first.
Some(server) if key.is_some() || (server.contains("key=") && !server.contains("token=")) => {
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone() };
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone(), share: false };
let file = match s.net(NetRequest::Upload(opts))?.0 {
NetReply::Upload(r) => r.map_err(|e| bad(cmd, e))?,
_ => return Err(EngineError::Other("the server answered something else".into())),

View File

@ -256,19 +256,24 @@ pub(crate) fn remove_item(n: &mut Node, list: BoundList, index: usize) -> bool {
true
}
/// Set `field` (`size|count|gutter|offset`) of layout grid `i` to a bound number.
pub(crate) fn set_grid_field(grids: &mut [LayoutGrid], i: usize, field: &str, x: f64) {
let Some(g) = grids.get_mut(i).filter(|_| x.is_finite()) else { return };
match field {
"size" => g.size = x.max(0.0),
"count" => g.count = x.round().clamp(1.0, 1000.0) as u32,
"gutter" => g.gutter = x.max(0.0),
"offset" => g.offset = x,
_ => {}
}
}
/// Write bound numbers into a node's layout grids (`layoutGrids/<i>/<size|count|gutter|offset>`;
/// only what changed, so unchanged layers stay shared).
pub(crate) fn apply_grid_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, f64)]) {
let Some(mut grids) = doc.node(id).map(|n| n.grids.clone()) else { return };
for (i, field, x) in sets {
let Some(g) = grids.get_mut(*i).filter(|_| x.is_finite()) else { continue };
match field.as_str() {
"size" => g.size = x.max(0.0),
"count" => g.count = x.round().clamp(1.0, 1000.0) as u32,
"gutter" => g.gutter = x.max(0.0),
"offset" => g.offset = *x,
_ => {}
}
set_grid_field(&mut grids, *i, field, *x);
}
if doc.node(id).is_some_and(|n| n.grids != grids)
&& let Ok(n) = doc.get_mut(id)

View File

@ -93,6 +93,7 @@ fn registry() -> &'static Registry {
list.extend(crate::codegen::specs());
list.extend(crate::devinspect::specs());
list.extend(crate::component_codegen::specs());
list.extend(crate::playground::specs());
list.extend(crate::code_connect::specs());
list.extend(crate::annotations::specs());
list.extend(crate::dev_resources::specs());
@ -103,7 +104,9 @@ fn registry() -> &'static Registry {
list.extend(crate::plugin_directory::specs());
list.extend(crate::widgets::specs());
list.extend(crate::live::specs());
list.extend(crate::online::specs());
list.extend(crate::invite_links::specs());
list.extend(crate::templates::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {

View File

@ -26,9 +26,13 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
let background = s.new_page_background();
let doc = s.doc_mut()?;
let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1));
let id = doc.add_page(&name);
if let NodeKind::Page { background: b, .. } = &mut doc.get_mut(id)?.kind {
*b = background;
}
if doc.is_divider(id) {
return Ok(json!({"id": id, "divider": true}));
}

View File

@ -16,7 +16,7 @@ pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relat
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, gridAlignH / gridAlignV (a grid child's alignment in its cell: min|center|max, auto/null = the frame's), sizingH / sizingV (fixed|hug|fill), \
absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
@ -293,16 +293,26 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
"width" | "height" | "w" | "h" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
let (ow, oh, keep) = {
let is_w = k.starts_with('w');
// With the proportions locked (the Layout panel's link) and only one side set, the
// other side follows the node's ratio; both sides in one `node.set` stay as typed.
let (nw, nh) = {
let n = doc.get(id)?;
(n.width, n.height, n.constrain_proportions && !props.contains_key("width") || !props.contains_key("height"))
let (ow, oh) = (n.width, n.height);
let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") };
let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9;
let derived = if is_w { oh * val / ow } else { ow * val / oh };
match (is_w, linked && derived.is_finite()) {
(true, true) => (val, derived),
(false, true) => (derived, val),
(true, false) => (val, oh),
(false, false) => (ow, val),
}
};
let _ = keep;
let (nw, nh) = if k.starts_with('w') { (val, oh) } else { (ow, val) };
resize_node(doc, id, nw, nh)?;
// Explicit size makes text fixed in that dimension.
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
t.auto_resize = if k.starts_with('w') {
t.auto_resize = if is_w {
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
} else {
TextAutoResize::None
@ -310,7 +320,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
let n = doc.get_mut(id)?;
if k.starts_with('w') {
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
@ -472,6 +482,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
"crossAlign" | "counterAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
}
"contentAlign" | "counterAxisAlignContent" => {
ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
}
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
@ -795,7 +808,7 @@ pub fn override_group(k: &str) -> &'static str {
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" => "alignment",
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
"grids" | "layoutGrids" | "gridStyle" => "grids",
"exports" | "exportSettings" => "exports",
"interactions" => "interactions",

View File

@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.set",
"Edit interaction",
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?}",
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
true,
set
),
@ -358,6 +358,32 @@ fn duration(p: &Value) -> Option<u32> {
num(p, "durationMs").or_else(|| num(p, "duration_ms")).map(|d| d.clamp(0.0, 60_000.0) as u32)
}
/// The delay of a mouse enter, leave, down or up trigger (`delayMs`), capped.
fn delay(cmd: &str, p: &Value) -> Result<Option<u32>> {
match get(p, "delayMs").or_else(|| get(p, "delay_ms")) {
None | Some(Value::Null) => Ok(None),
Some(v) => match f64_of(v).filter(|d| d.is_finite() && *d >= 0.0) {
Some(d) => Ok(Some(d.min(f64::from(MAX_DELAY_MS)).round() as u32)),
None => Err(bad(cmd, "delayMs: milliseconds, 0 or more")),
},
}
}
/// A delay only where it applies (mouse enter, leave, down and up), and a manual offset only on a
/// manual overlay, within what the player places.
fn settle(it: &mut Interaction) {
if it.delay_secs() == 0.0 {
it.delay_ms = 0;
}
it.delay_ms = it.delay_ms.min(MAX_DELAY_MS);
walk_mut(&mut it.actions, &mut |a| {
if let Action::OpenOverlay { position, offset, .. } = a {
*offset =
offset.filter(|o| *position == OverlayPosition::Manual && o.iter().all(|c| c.is_finite())).map(|o| o.map(|c| c.clamp(-1e5, 1e5)));
}
});
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.interaction.add";
let p = &super::proto_actions::with_expressions(cmd, &s.doc()?.variables, p)?;
@ -370,6 +396,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
transition: parse(cmd, p, "transition")?.unwrap_or_default(),
duration_ms: duration(p).unwrap_or(300),
easing: parse(cmd, p, "easing")?.unwrap_or_default(),
delay_ms: 0,
}
} else {
let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default();
@ -379,9 +406,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
let transition: Transition = parse(cmd, p, "transition")?.unwrap_or_default();
let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default();
Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing }
Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing, delay_ms: 0 }
};
let mut it = it;
it.delay_ms = delay(cmd, p)?.unwrap_or(0);
settle(&mut it);
canonical_ids(s.doc()?, &mut it);
check_dests(cmd, s, &it)?;
let mut index = 0;
@ -408,6 +437,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
let transition: Option<Transition> = parse(cmd, p, "transition")?;
let easing: Option<Easing> = parse(cmd, p, "easing")?;
let dur = duration(p);
let wait = delay(cmd, p)?;
let mut hit = false;
for i in req_targets(cmd, s, p)? {
let Some(cur) = s.doc()?.get(i)?.interactions.get(k).cloned() else { continue };
@ -433,6 +463,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = dur {
it.duration_ms = d;
}
if let Some(d) = wait {
it.delay_ms = d;
}
settle(&mut it);
if it.actions.is_empty() {
return Err(bad(cmd, "an interaction needs an action"));
}

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