diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index e4cd6a93..65ba272f 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: figcraft app-dir: FigCraft diff --git a/AGENTS.md b/AGENTS.md index b2384311..87cfc30b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,3 +131,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index f61412ba..f9698123 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,3 +132,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 05adf9fc..bc2dcbc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1848,6 +1848,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", ] [[package]] @@ -2100,6 +2101,7 @@ dependencies = [ "egui", "figcraft-collab-net", "figcraft-engine", + "figcraft-format", "figcraft-text", "figcraft-ui-egui", "js-sys", diff --git a/ROADMAP.md b/ROADMAP.md index 519f94d6..12dac463 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-29 · **Change:** minor (figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -355,6 +355,26 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1101: the importers' 256-level depth cap could overflow the browser's 1 MB stack before it was reached. Measured per level: the REST import's layer walk (also the second stage of every `.fig` import) took about 4 KB, a whole megabyte at 256, and overflowed a 1 MB thread; it now runs on a work list in the same order (groups fitted once their children are in), a few KB in all. The `.fig` mapping, which builds each layer from its children's results, stays recursive at about 1.8 KB a level, 455 KB at the cap. Desktop and browser keep the 256-level cap, so nothing that loads today is cut off. Numbers unchanged. Tests: `deep_import_tests` (256 and 400 levels on a 1 MB thread). | +| 2026-10-30 | minor | M8, #1082 (the rest): the SVG and PDF exporters drew a pattern fill's source from depth 0 again, and the renders they embed (a layer with effects drawn alone, a pattern cell with effects) started at 0 too, so an export could nest its own levels, the renderer's and a pattern's on top of each other. A pattern source now exports one level below the layer it fills, and what the renderer draws for a layer goes on from that layer's depth (`figcraft_render::at_layer_depth`), so one cap bounds the whole export; the render of what lies beneath a layer (background blur, glass) is of the page's own layers and still counts from the page. Canvas masks, mask content and boolean and outline walks already carried the depth or have their own small caps. Numbers unchanged. Tests: `render_depth_tests`, `figcraft-svg` and `figcraft-pdf` `pattern_depth_tests`. | +| 2026-10-30 | minor | M8, #1082: a pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels; its source now goes on from the depth of the layer it fills, and the one cap bounds the whole render. Measured, a level takes about 6.5 KB of stack in a render (6.8 KB in a debug build), so the deepest allowed render needs about 1.7 MB: the canvas render threads, the save thread (it renders the thumbnail) and a band pool of their own now get 8 MiB stacks, like the desktop app's main thread (rayon's and other threads' default is 2 MiB). Numbers unchanged. Tests: `render_depth_tests` (the cap reached through a pattern; three 250-level chains of patterns in patterns rendered on a render thread's stack). | +| 2026-10-30 | minor | M8, #1114 (explicit variables files only): sibling variables files were read with a 256 MiB cap and a regular-file check, but an explicit path given to `variable.import`, or as the `variables` of `file.open` and `file.import`, was read whole with no limit. That path now goes through the same capped read: an oversized file, a folder or a device fails with "not a file of at most 268435456 bytes" and the document, tabs and recent files stay as they were. The check reads the file's size before reading it, so a file that grows or is replaced in between is not bounded; primary document reads and `image.place` are left for the rest of #1114. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | +| 2026-10-30 | minor | M8, #1074: on `figcraft-server` every join of an idle file read its snapshot and log back and every join and leave rewrote its `meta.json`, all on the hub thread, so join/leave loops amplified into disk I/O. A file everyone left (its log synced) now stays loaded for a minute (at most 16 such files, 256 MiB together), and a join in that time takes it as it was with nothing read; its open time, which expiry counts in days, is written at most hourly and when the server stops. Edits are saved as before (logged, compacted when everyone leaves). Measured: 20 join/leave cycles of an unchanged file did 0 reads and 0 writes (40 reads before, plus a `meta.json` write per join and leave). Numbers unchanged. Tests: `idle_io_tests`. | +| 2026-10-30 | minor | M8, #1079: one budget for everything a `.figcraft` file unpacks when it opens. Each area had its own cap (document 4 GiB, images 4 GiB, library 1.5 GiB, versions 1 GiB, branches 1 GiB), so one file could make the app unpack about 10 GB. Now they also share one total, 6 GiB (2 GiB on 32-bit targets such as the browser), counted member by member in reading order: document, images, published library, version history, branches; the per-area caps stay. The document (a 900 000-layer one unpacks to 1.5 GB) and the images up to their own cap fit within it, so a file whose images pass 4 GiB (#1313) keeps as many as before, and images left out by the total are treated like those past the images' cap: they stay in the file and saving over it is refused rather than dropping them. History and branches past the total are skipped, and a document past it refused, each with the note "the file's contents together pass … MB". Numbers unchanged. Tests: `unpacked_total_tests`. | +| 2026-10-30 | minor | M8, #1571 (part): laggy drags and high CPU while the pointer only moves. Measured on a synthetic file of about 1,900 layers (24 auto layout frames of 48 rows, 600 shapes, 24 images; release build, 300 to 600 pointer moves at 60 per second): every pointer move draws about three egui frames (the move, egui's follow-up, a finished render), and the canvas hit-tested the hover on each of them; a drag of an auto layout frame laid its contents out again on every move, though a move can't change them. Now a hover where nothing changed since the last (same place, file, page, revision, selection, tool and modes) isn't sent to the tools again, and a frame that only moved keeps its own layout (its parent's still runs). Hover: the UI pass 0.91 to 0.49 ms a frame, CPU 33% to 22% of a core; dragging 648 layers: the UI pass 3.8 to 3.2 ms, the engine's normalise 3.8 to 2.1 ms a move, CPU about the same (117% to 116%). `ui.inspect` reports `frameTimes` ({frames, meanMs, maxMs}). Not yet: the selection overlay and the Design panel recompute every frame for a large selection (about 2 ms each at 648 layers), the canvas texture is uploaded whole after each render, and egui draws its follow-up frames. Tests: `canvas_hover_tests.rs`, `auto_layout_move_tests.rs`. | +| 2026-10-30 | minor | M8, #1077: two quadratic costs in the `.fig` import's instance expansion. Variable and collection nodes sat in the layer tree, so every instance of a main walked the ones under it again, and as they never become layers the layer cap never stopped them; they are now left out of the tree (variables are read from the node list as before), so everything an expansion visits counts against the cap. A nested instance copied all of its outer instance's override rows, and its property values and slot contents; it now takes only the rows addressed below it (they sort together, so the rest aren't visited) and shares the outer values, keeping only its own. Normal files map as before. Crafted files: 20 000 variables under a main with 2 000 instances 13.8 s → 21 ms; 50 000 override rows over 2 000 nested instances 40.5 s → 98 ms (native release, the same output). Numbers unchanged. Tests: `expansion_cost_tests`. | +| 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists every name a message @mentions besides those who wrote or reacted (once each in any case, a known name spelt as the file spells it, never an email address), so a name that has only been tagged shows up. `comment.next` takes the `mentions` filter and, like `comment.open`, returns the thread as `comment.list` lists it, with each message's `mentions`; `comment.prompt`'s bounded `comments` carry each thread's mentions (at most 32, names cut to 80 characters). One mention helper serves them all. Numbers unchanged. Tests: `comment_mentions_tests`. | +| 2026-10-30 | minor | M8, #1083: grid auto-placement tested every cell of a child's span at every position it tried (children × rows × columns × span area), so a crafted grid of wide children kept every layout pass busy. The grid's taken cells are now a bitmap: a span is checked a 64-bit word at a time, the search jumps past the cell that blocks it, a row's search ORs the span's rows once, and a column-anchored child looks on from where the last one of the same column and spans found room (cells only ever get taken). Placements are the same, checked against the old search on 400 random grids. A crafted 1000 × 1000 grid with 1000 full-width children: 192 ms → 0.3 ms; 3000 column-anchored wide children: 4.2 ms → 0.7 ms; ordinary grids unchanged (20 000 1 × 1 children 5.2 ms either way). Numbers unchanged. Tests: `grid_place_tests`. | +| 2026-10-30 | minor | M1, #1430: from the pen into vector edit mode. A double-click with the pen drew on: its second press added a point on top of the last one (a zero-length segment), a double-click meant to edit a layer drew a stray two-point vector on it, and Enter ran what Escape does. Now a press on the pen's last anchor adds nothing; a double-click while drawing ends the path at that point, and one that only started a path drops it and edits the layer under the pointer; Enter ends the path. Both go on editing the vector with the Move tool (points and handles); the next Enter leaves edit mode. In edit mode the pen's double-click and Enter take the Move tool too, a double-click taking back the point or new path its first press began. Escape still keeps the pen in edit mode for the next path (#760). Numbers unchanged. Tests: `pen_vector_edit_tests`. | +| 2026-10-30 | minor | M1: desktop recovery copies made while a version is previewed held the version shown instead of the file, so a crash during a preview brought the old version back as the unsaved file. What a recovery copy holds is now one shared helper, `figcraft_engine::branches::recovery_copy`: the document itself (`DocState::document_itself`, the same document the unsaved-changes check compares, not the version or live text preview shown in its place), its version history and its branches. The desktop copies and the browser's (#1580, which already took the document itself) both use it. Tests: `recovery_tests.rs` in the desktop and web apps (a copy made during a version preview recovers the file and its history). | +| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | +| 2026-10-30 | minor | M6, #1342: FigJam `.jam` files open. A `.jam` is the same archive as a `.fig` (its canvas header reads `fig-jam.`), and the `.fig` reader already took it when a copy was renamed; now File ▸ Open, its file filter, drag and drop (home screen and canvas), `file.import` and the command line take `.jam` too, and the home screen's recent files show their thumbnails. What comes in is what the `.fig` reader makes of FigJam layers: stickies and shapes with text as frames with their generated layers, tables, stamps and the like as frames with an import note, connectors as vectors, and a layer type it doesn't know left out with a note naming it. The file opens as a design file, not a board, and its objects carry no board metadata yet. Tests: a synthetic `fig-jam.` canvas, bare and zipped (`board_import_tests.rs`), a `.jam` made from our own `.fig` export opened and imported (`jam_open_tests.rs`), the extension checks (`home_tests.rs`). | +| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. | +| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. | +| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. | +| 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. | +| 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). | +| 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index d92689df..02bdfbed 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -29,7 +29,7 @@ Usage: figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first) figcraft-server files remove FILE remove a file and its links for good figcraft-server links list FILE a file's share links - figcraft-server links create FILE --access edit|view [--label TEXT] + figcraft-server links create FILE --access edit|comment|view [--label TEXT] figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected) figcraft-server plugins [list] the plugin directory: every plugin and its versions figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one) @@ -124,7 +124,7 @@ pub fn parse(args: &[String]) -> Result { "--listen" => config.listen = value("--listen")?, "--data" => config.data = PathBuf::from(value("--data")?), "--public-url" => config.public_url = Some(value("--public-url")?), - "--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?), + "--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit, comment or view")?), "--label" => label = value("--label")?, "--rotate" => rotate = true, "--sort" => { @@ -147,7 +147,7 @@ pub fn parse(args: &[String]) -> Result { ["files", "remove", file] => Command::RemoveFile { file: file.to_string() }, ["links", "list", file] => Command::Links { file: file.to_string() }, ["links", "create", file] => { - Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label } + Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit, comment or view")?, label } } ["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() }, ["plugins"] | ["plugins", "list"] => Command::Plugins, @@ -361,8 +361,7 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result { }; let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() }; let mut out = String::new(); - let what = if l.link.access == Access::View { "view" } else { "edit" }; - let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file); + let _ = writeln!(out, "New {} link {} for file {}:", l.link.access.id(), l.link.id, l.link.file); let _ = writeln!(out, " {}", invite.link()); let _ = writeln!(out, " {}", invite.url()); let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token)."); diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index e226d2ec..cbc7f70a 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -57,7 +57,7 @@ fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: & assert!(!links.contains(secret), "{links}"); } assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token"); - assert_eq!(links.matches("\"code\"").count(), 2); + assert_eq!(links.matches("\"code\"").count(), 3); } #[test] @@ -76,7 +76,8 @@ fn a_file_shared_online_is_joined_by_its_codes() { assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise"); let p = a.live().published.clone().unwrap(); assert!(p.owner.is_some(), "the owner token is kept as before"); - // Others get links of their own (the owner keeps the file's first ones): four in all. + // Others get links of their own (edit, view and comment; the owner keeps the file's first + // ones): five in all. let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string(); assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true); let ids = |dir: &std::path::Path| -> Vec { @@ -84,7 +85,7 @@ fn a_file_shared_online_is_joined_by_its_codes() { links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect() }; let before_stop = ids(dir.path()); - assert_eq!(before_stop.len(), 4); + assert_eq!(before_stop.len(), 5); // On disk: hashes only, of the codes as of the links' tokens. only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view); // Someone types the edit code, the way people type. @@ -142,7 +143,7 @@ fn a_file_shared_online_is_joined_by_its_codes() { let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string()); assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}"); assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server"); - assert_eq!(ids(dir.path()).len(), 4); + assert_eq!(ids(dir.path()).len(), 5); ex(&mut b, "collab.join", json!({"code": edit2})); pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1); // The old code is still gone. diff --git a/apps/figcraft-server/src/comment_link_tests.rs b/apps/figcraft-server/src/comment_link_tests.rs new file mode 100644 index 00000000..50af8a01 --- /dev/null +++ b/apps/figcraft-server/src/comment_link_tests.rs @@ -0,0 +1,145 @@ +//! Comment links end to end over loopback: sharing online makes a comment link and code beside the +//! edit and view ones; someone who joins with it comments and everyone sees it, their app refuses +//! their other edits, and the server refuses them too for a client that skips the check; a client +//! that can't read comment links gets in as a viewer, and links listed to it read as view links. + +use std::time::Duration; + +use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg}; +use figcraft_collab::{COMMENT_ONLY, Change, Client, Entry, Json}; +use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage}; +use figcraft_doc::{Comment, Message}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// The welcome `raw` gets after saying hello with `client_version` through `link`. +fn welcome(raw: &mut Raw, link: &str, client_version: &str) -> Client { + let invite = Invite::parse(link).unwrap(); + let ClientMsg::Hello(mut h) = Client::hello(&invite.file, &invite.token, "Raw", "") else { panic!("not a hello") }; + h.client_version = client_version.into(); + raw.send(&ClientMsg::Hello(h)); + loop { + if let w @ ServerMsg::Welcome { .. } = raw.next() { + return Client::join(w).unwrap(); + } + } +} + +/// What the server says to the batch `raw` sends next. +fn answer(raw: &mut Raw) -> ServerMsg { + loop { + if let m @ (ServerMsg::Reject { .. } | ServerMsg::Changes(_)) = raw.next() { + return m; + } + } +} + +#[test] +fn a_comment_link_comments_and_the_server_refuses_its_edits() { + let dir = TempDir::new("comment-link"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + ex(&mut a, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 80})); + let p = publish(&mut a, dir.path(), addr); + let file = Invite::parse(&p.edit).unwrap().file; + let endpoint = Endpoint::plain(addr.to_string()); + let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0); + let AdminReply::Link(made) = ask(Admin::CreateLink { file: file.clone(), access: Access::Comment, label: "Client".into() }) else { panic!() }; + assert_eq!(made.link.access, Access::Comment); + let link = Invite { server: endpoint.clone(), file: file.clone(), token: made.token.clone() }.link(); + // Cy joins with it and comments; Ada sees the thread. + let mut c = join(&link, "Cy"); + pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("comment"), &json!(true))); + let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Bigger logo?"}))["id"].as_u64().unwrap(); + pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some()); + ex(&mut a, "comment.reply", json!({"id": thread, "text": "On it"})); + pump(&mut [&mut a, &mut c], "the reply at Cy", |s| s[1].doc().unwrap().comment(thread).is_some_and(|t| t.replies.len() == 1)); + ex(&mut c, "comment.resolve", json!({"id": thread})); + pump(&mut [&mut a, &mut c], "resolved at Ada", |s| s[0].doc().unwrap().comment(thread).is_some_and(|t| t.resolved)); + // Cy's app refuses anything else before it is sent. + let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string(); + assert!(e.contains("Can comment"), "{e}"); + // A client that skips the app's check: its comment goes through, its edit is refused. + let mut raw = Raw::connect(addr); + let mut client = welcome(&mut raw, &link, &figcraft_collab::message::client_version()); + assert_eq!(client.access(), Access::Comment); + let mut d = client.document().clone(); + let page = d.first_page().unwrap(); + let id = d.new_comment_id(); + d.comments.push(Comment { + id, + page, + x: 1.0, + y: 1.0, + anchor: None, + first: Message::new("Raw", "Hi", 0), + resolved: false, + replies: vec![], + read_by: None, + }); + raw.send(&client.edit(&d).unwrap().unwrap()); + let ServerMsg::Changes(acc) = answer(&mut raw) else { panic!("refused") }; + client.receive(ServerMsg::Changes(acc), &figcraft_collab::NoRecompute).unwrap(); + let mut d = client.document().clone(); + d.node_mut(page).unwrap().name = "Sneaky".into(); + raw.send(&client.edit(&d).unwrap().unwrap()); + assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY)); + // So is a forged batch that hides an edit among comment changes. + let forged = Change::Put { entry: Entry::Comment(id), value: Json::of(&client.document().comments[0]).unwrap() }; + let rename = Change::SetDoc { prop: figcraft_collab::DocProp::Name, value: Json::of("Sneaky").unwrap() }; + raw.send(&ClientMsg::Changes { base_seq: 0, batch_id: 99, changes: vec![forged, rename] }); + assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY)); + pump(&mut [&mut a], "the raw comment, nothing else", |s| { + let d = s[0].doc().unwrap(); + d.comment(id).is_some() && d.node(page).is_some_and(|n| n.name != "Sneaky") && d.name != "Sneaky" + }); + // A client from before comment links gets in as a viewer. + let mut old = Raw::connect(addr); + assert_eq!(welcome(&mut old, &link, "0.12.1 signals hosting").access(), Access::View); + // And lists the link as a view link. + let mut m = Raw::connect(addr); + m.send(&ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key(dir.path()), client_version: "0.12.1".into() })); + assert!(matches!(m.next(), ServerMsg::Admin(r) if *r == AdminReply::Ready)); + m.send(&ClientMsg::Admin(Admin::Links { file: file.clone() })); + let ServerMsg::Admin(r) = m.next() else { panic!("no listing") }; + let AdminReply::Links(links) = *r else { panic!("{r:?}") }; + assert!(links.iter().all(|l| l.access != Access::Comment) && links.iter().any(|l| l.id == made.link.id && l.access == Access::View)); + let AdminReply::Links(links) = ask(Admin::Links { file }) else { panic!() }; + assert!(links.iter().any(|l| l.id == made.link.id && l.access == Access::Comment), "this build reads it"); + server.stop(); +} + +#[test] +fn sharing_online_offers_a_comment_code() { + let dir = TempDir::new("comment-code"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let url = format!("ws://{}/?key={}", server.addr(), key(dir.path())); + let mut a = session("Ada"); + ex(&mut a, "collab.share", json!({"server": url})); + pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live_summary()["share"]["shared"] == true); + let shared = a.live_summary()["share"].clone(); + let comment = shared["commentCode"].as_str().unwrap().to_string(); + assert!(comment != shared["editCode"] && comment != shared["viewCode"], "{shared}"); + assert_eq!(ex(&mut a, "collab.share", json!({"access": "comment"}))["code"], comment.as_str()); + // Someone types it: they can comment, not edit. + let mut c = session("Cy"); + ex(&mut c, "pref.publishServer", json!({"url": format!("ws://{}/", server.addr())})); + ex(&mut c, "collab.join", json!({"code": comment})); + pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!(c.live_summary()["access"], "comment"); + let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Looks good"}))["id"].as_u64().unwrap(); + pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some()); + assert!(c.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err()); + // Stopping revokes it with the others: Cy is out. + ex(&mut a, "collab.stop", json!({})); + pump(&mut [&mut a, &mut c], "Cy out", |s| !s[1].live().status.active() && s[0].live().peers.is_empty()); + server.stop(); +} diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index 312b4aea..cf56ea26 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -78,6 +78,7 @@ fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> { #[cfg(not(unix))] let _ = private; let mut f = o.open(&tmp)?; + counted(true); f.write_all(bytes)?; f.sync_all()?; } @@ -101,6 +102,7 @@ pub fn sync_dir(dir: Option<&Path>) { /// Read a file of at most `max` bytes (a longer one is refused without being read). pub fn read_capped(path: &Path, max: u64) -> Result, String> { let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?; + counted(false); let len = f.metadata().map_err(|e| e.to_string())?.len(); if len > max { return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len)); @@ -110,6 +112,29 @@ pub fn read_capped(path: &Path, max: u64) -> Result, String> { Ok(bytes) } +#[cfg(test)] +thread_local! { + /// Files read and written on this thread (tests: what an action costs, #1074). + static IO: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// Count a file read or write (a log append counts as a write). +pub(crate) fn counted(write: bool) { + #[cfg(test)] + IO.with(|io| { + let (r, w) = io.get(); + io.set(if write { (r, w + 1) } else { (r + 1, w) }); + }); + #[cfg(not(test))] + let _ = write; +} + +/// The files read and written on this thread since the last call: `(reads, writes)`. +#[cfg(test)] +pub(crate) fn take_io() -> (u32, u32) { + IO.with(|io| io.replace((0, 0))) +} + fn name(path: &Path) -> String { path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() } diff --git a/apps/figcraft-server/src/idle_io_tests.rs b/apps/figcraft-server/src/idle_io_tests.rs new file mode 100644 index 00000000..9df754a4 --- /dev/null +++ b/apps/figcraft-server/src/idle_io_tests.rs @@ -0,0 +1,162 @@ +//! Joining and leaving a file nobody is editing costs no disk I/O while it is kept loaded +//! (#1074), and what changes is still saved as before. + +use std::fs; + +use figcraft_collab::message::{Access, ClientMsg, Hello}; +use figcraft_collab::{Client, NoRecompute, Outgoing, Replica, Server}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::Document; + +use crate::disk::{self, META}; +use crate::store::{FILES, Store, StoreConfig}; +use crate::test_util::{TempDir, config}; + +fn hello() -> Hello { + let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() }; + h +} + +/// Let `hello` into `server` and hand its messages to `client` (a new one when `None`); the +/// client and its id there. +fn enter(server: &mut Server, client: Option, hello: Hello) -> (Client, u32) { + let (id, out) = server.join_as(hello, Access::Edit).unwrap(); + let mut msgs = out.into_iter().filter_map(|o| match o { + Outgoing::To(to, m) if to == id => Some(m), + _ => None, + }); + let client = match client { + Some(mut c) => { + for m in msgs { + c.receive(m, &NoRecompute).unwrap(); + } + c + } + None => Client::join(msgs.next().unwrap()).unwrap(), + }; + (client, id) +} + +/// A file opened, joined and left once: the store, the file's id and the client, ready to resume. +fn published(dir: &TempDir) -> (Store, String, Client, StoreConfig) { + let c = StoreConfig { compact_after: 1000, ..config(dir.path()).store() }; + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let d = Document::new("Idle"); + let (info, _, _) = store.publish("Idle", &Replica::new(d).snapshot().unwrap()).unwrap(); + let id = info.id; + let mut server = store.open(&id).unwrap(); + let (client, cid) = enter(&mut server, None, hello()); + server.leave(cid); + store.changed(&id, &mut server); + store.flush(); + assert!(store.idle(&id, &mut server)); + (store, id, client, c) +} + +/// One rejoin and leave, as the hub drives it; `edit` renames the page on the way. +fn cycle(store: &mut Store, id: &str, client: Client, edit: Option<&str>) -> Client { + let mut server = store.open(id).unwrap(); + let (mut client, cid) = { + let mut c = client; + let ClientMsg::Hello(h) = c.reconnect(hello()) else { panic!() }; + enter(&mut server, Some(c), h) + }; + if let Some(name) = edit { + let mut d = client.document().clone(); + let page = d.first_page().unwrap(); + d.node_mut(page).unwrap().name = name.into(); + let m = client.edit(&d).unwrap().unwrap(); + for o in server.receive(cid, m) { + if let Outgoing::All(m) | Outgoing::To(_, m) = o { + client.receive(m, &NoRecompute).unwrap(); + } + } + } + server.leave(cid); + store.changed(id, &mut server); + store.flush(); + assert!(store.idle(id, &mut server)); + client +} + +fn page_name(server: &Server) -> String { + let d = server.replica().document(); + d.first_page().and_then(|p| d.node(p)).map(|n| n.name.clone()).unwrap_or_default() +} + +#[test] +fn rejoining_an_unchanged_file_reads_and_writes_nothing() { + let dir = TempDir::new("idle-io"); + let (mut store, id, mut client, _) = published(&dir); + let log = dir.path().join(FILES).join(&id).join(crate::disk::LOG); + let log_len = fs::metadata(&log).unwrap().len(); + disk::take_io(); + for _ in 0..20 { + client = cycle(&mut store, &id, client, None); + } + assert_eq!(disk::take_io(), (0, 0), "20 join/leave cycles: no file read or written"); + assert_eq!(fs::metadata(&log).unwrap().len(), log_len); + assert_eq!(store.compactions(), 0); +} + +#[test] +fn edits_while_kept_loaded_are_still_saved() { + let dir = TempDir::new("idle-edits"); + let (mut store, id, mut client, c) = published(&dir); + for i in 0..3 { + client = cycle(&mut store, &id, client, Some(&format!("Page {i}"))); + } + // A cycle with an edit compacts it on leaving, as before; the next one does nothing again. + assert!(store.compactions() > 0); + disk::take_io(); + let _ = cycle(&mut store, &id, client, None); + assert_eq!(disk::take_io(), (0, 0)); + // Saved: another store (a restart) reads it back. + drop(store); + let mut again = Store::open(dir.path(), c).unwrap(); + disk::take_io(); + let server = again.open(&id).unwrap(); + assert_eq!(page_name(&server), "Page 2"); + assert_eq!(disk::take_io().0, 2, "one load: the snapshot and the log"); +} + +#[test] +fn the_file_kept_loaded_longest_lets_go_first() { + let dir = TempDir::new("idle-many"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let ids: Vec = + (0..17).map(|i| store.publish(&format!("F{i}"), &Replica::new(Document::new("F")).snapshot().unwrap()).unwrap().0.id).collect(); + for id in &ids { + let mut server = store.open(id).unwrap(); + assert!(store.idle(id, &mut server)); + } + disk::take_io(); + let _ = store.open(&ids[16]).unwrap(); + assert_eq!(disk::take_io(), (0, 0), "the latest is still loaded"); + let _ = store.open(&ids[0]).unwrap(); + assert_eq!(disk::take_io(), (2, 0), "past 16, the first let go: read again"); +} + +#[test] +fn the_open_time_is_written_hourly_and_when_the_server_stops() { + let dir = TempDir::new("idle-opened"); + let (mut store, id, client, _) = published(&dir); + let opened = || -> u64 { + let meta: serde_json::Value = serde_json::from_slice(&fs::read(dir.path().join(FILES).join(&id).join(META)).unwrap()).unwrap(); + meta["opened"].as_u64().unwrap() + }; + // Last written half an hour ago: a join doesn't write it yet; stopping does. + store.age(&id, 1800); + let before = opened(); + disk::take_io(); + let client = cycle(&mut store, &id, client, None); + assert_eq!(disk::take_io(), (0, 0)); + assert_eq!(opened(), before); + store.stop(Vec::new()); + assert!(opened() >= before + 1800); + // Written over an hour ago: the next join writes it. + store.age(&id, 4000); + disk::take_io(); + let _ = cycle(&mut store, &id, client, None); + assert_eq!(disk::take_io(), (0, 1)); +} diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index 746205d1..c488d19f 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -95,10 +95,14 @@ mod cli_tests; #[cfg(test)] mod codes_tests; #[cfg(test)] +mod comment_link_tests; +#[cfg(test)] mod directory_tests; #[cfg(test)] mod hardening_tests; #[cfg(test)] +mod idle_io_tests; +#[cfg(test)] mod library_tests; #[cfg(test)] mod owned_tests; diff --git a/apps/figcraft-server/src/owned_tests.rs b/apps/figcraft-server/src/owned_tests.rs index b4df0502..767b9e39 100644 --- a/apps/figcraft-server/src/owned_tests.rs +++ b/apps/figcraft-server/src/owned_tests.rs @@ -158,7 +158,7 @@ fn only_a_files_own_token_learns_about_it() { assert_eq!(list.len(), 5, "one answer per pair, in order"); let first = list[0].as_ref().expect("its own token"); assert_eq!(first.name, "Untitled"); - assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others"); + assert_eq!(first.links.len(), 5, "the owner's two links and the three made for others"); assert!(first.expires.is_some()); assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same"); // Nothing asked: nothing answered; too many: refused, and the connection stays usable. diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 2108bfb8..9e12d328 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -241,8 +241,30 @@ struct Entry { bytes: u64, /// When it was last compacted to make room (seconds since 1970). forced: u64, + /// Closed lately: its session kept loaded ([`PARK_FOR`]). + parked: Option, + /// The `opened` time `meta.json` holds ([`TOUCH_EVERY`]). + opened_written: u64, } +/// A file everyone left lately, kept loaded so a join soon after neither reads it nor rewrites it +/// (#1074): its session and its log (synced, its state compacted as on any close). +struct Parked { + server: Server, + log: Log, + at: Instant, +} + +/// How long a file everyone left stays loaded. +const PARK_FOR: std::time::Duration = std::time::Duration::from_secs(60); +/// Most files kept loaded after everyone left (the one parked longest goes first). +const MAX_PARKED: usize = 16; +/// Most bytes (on disk) of the files kept loaded after everyone left. +const MAX_PARKED_BYTES: u64 = 256 << 20; +/// How often, at most, a file's `opened` time is written while it keeps being opened (seconds): +/// expiry counts in days, so it needn't be written at every join and leave. +const TOUCH_EVERY: u64 = 3600; + /// A library's usage reports in memory, and whether (and when) they were written. struct UsageCache { list: Vec, @@ -494,7 +516,8 @@ impl Store { log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display()); } let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum(); - Ok(Entry { meta, links, log: None, bytes, forced: 0 }) + let opened_written = meta.opened; + Ok(Entry { meta, links, log: None, bytes, forced: 0, parked: None, opened_written }) } /// The server key (for managing files). diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index fc36a337..91bdc3ac 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -62,12 +62,14 @@ impl Backend for Store { } fn idle(&mut self, file: &str, server: &mut Server) -> bool { + let mut synced = false; let compact = self.files.get_mut(file).is_some_and(|entry| { let snapshot_seq = entry.meta.seq; entry.log.as_mut().is_some_and(|log| { // Issued client ids and refusals are durable in the log; only edits need a // new image-bearing snapshot. Still try a snapshot if syncing the log failed. - flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + synced = flush_log(file, log).is_ok(); + !synced || (log.dirty && server.seq() > snapshot_seq) }) }); if compact { @@ -75,8 +77,12 @@ impl Backend for Store { } // It closes anyway, with its state in the snapshot and durable log. self.cut_short.retain(|f| f != file); - if let Some(e) = self.files.get_mut(file) { - e.log = None; + let log = self.files.get_mut(file).and_then(|e| e.log.take()); + // Kept loaded a while, when its log was sound: a join soon after reads and rewrites + // nothing (#1074). The hub drops its room as this returns: the session moves here. + if let Some(log) = log.filter(|l| synced && l.pending.is_empty()) { + let left = std::mem::replace(server, Server::new(figcraft_collab::Document::new(""), "", self.cfg.core)); + self.park(file, left, log); } // Everyone just left: idle time counts from now. self.touch(file); @@ -86,6 +92,11 @@ impl Backend for Store { fn stop(&mut self, open: Vec<(&str, &mut Server)>) { self.write_usage(true); self.flush_all(); + // The open times not written yet (they are written at most hourly while files are used). + let unwritten: Vec = self.files.iter().filter(|(_, e)| e.meta.opened > e.opened_written).map(|(id, _)| id.clone()).collect(); + for file in unwritten { + self.write_opened(&file); + } for (file, server) in open { if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { self.compact(file, server); diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs index 565916ed..f0d8ddcb 100644 --- a/apps/figcraft-server/src/store/files.rs +++ b/apps/figcraft-server/src/store/files.rs @@ -1,7 +1,8 @@ use super::{ Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER, - MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server, - Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json, + MAX_BLOCKED, MAX_OWNED, MAX_PARKED, MAX_PARKED_BYTES, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, PARK_FOR, Parked, REPORTS, Replica, + ReportInfo, SNAPSHOT, SWEEP_EVERY, Server, Store, TOUCH_EVERY, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, + same_secret, token, write_json, }; impl Store { @@ -133,7 +134,9 @@ impl Store { let _ = fs::remove_dir_all(&dir); return Err(format!("couldn't keep the file: {e}")); } - let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; + let opened_written = meta.opened; + let (links, bytes_kept) = (vec![edit.clone(), view.clone()], (bytes.len() + LOG_HEADER) as u64); + let entry = Entry { meta, links, log: None, bytes: bytes_kept, forced: 0, parked: None, opened_written }; self.files.insert(id.clone(), entry); let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; match ip { @@ -199,6 +202,12 @@ impl Store { pub fn sweep(&mut self) -> Vec { let t = now(); self.next_sweep = t.saturating_add(SWEEP_EVERY); + // Files kept loaded after everyone left let go once their time is up. + for e in self.files.values_mut() { + if e.parked.as_ref().is_some_and(|p| p.at.elapsed() > PARK_FOR) { + e.parked = None; + } + } let due: Vec = self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); for id in &due { @@ -217,6 +226,7 @@ impl Store { if let Some(e) = self.files.get_mut(file) { let m = &mut e.meta; (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); + e.opened_written = m.opened; let _ = write_json(&path, m); } } @@ -232,17 +242,65 @@ impl Store { } } - /// Note that a file was opened now (it is used: it doesn't expire). + /// Note that a file was opened now (it is used: it doesn't expire). Expiry counts in days, so + /// `meta.json` gets the time at most every [`TOUCH_EVERY`] seconds (and when the server stops), + /// not at every join and leave (#1074). pub(super) fn touch(&mut self, file: &str) { + let t = now(); + let due = self.files.get_mut(file).is_some_and(|e| { + e.meta.opened = t; + t >= e.opened_written.saturating_add(TOUCH_EVERY) + }); + if due { + self.write_opened(file); + } + } + + /// Write `file`'s `opened` time to its `meta.json`. + pub(super) fn write_opened(&mut self, file: &str) { let path = self.dir(file).join(META); if let Some(e) = self.files.get_mut(file) { - e.meta.opened = now(); - if let Err(err) = write_json(&path, &e.meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); + match write_json(&path, &e.meta) { + Ok(()) => e.opened_written = e.meta.opened, + Err(err) => log::warn!("file {file}: couldn't update {META}: {err}"), } } } + /// Keep `file`'s session loaded for a while after everyone left ([`PARK_FOR`]), at most + /// [`MAX_PARKED`] files of at most [`MAX_PARKED_BYTES`] together (the ones parked longest let + /// go first; a file larger than that isn't kept). + pub(super) fn park(&mut self, file: &str, server: Server, log: super::Log) { + let size = self.bytes(file); + if size > MAX_PARKED_BYTES { + return; + } + let mut parked: Vec<(String, Instant, u64)> = + self.files.iter().filter_map(|(id, e)| e.parked.as_ref().map(|p| (id.clone(), p.at, e.bytes))).collect(); + parked.sort_by_key(|(_, at, _)| *at); + let mut held: u64 = parked.iter().map(|(_, _, b)| b).sum(); + let mut n = parked.len(); + for (id, _, b) in &parked { + if n < MAX_PARKED && held.saturating_add(size) <= MAX_PARKED_BYTES { + break; + } + if let Some(e) = self.files.get_mut(id) { + e.parked = None; + } + n -= 1; + held = held.saturating_sub(*b); + } + if let Some(e) = self.files.get_mut(file) { + e.parked = Some(Parked { server, log, at: Instant::now() }); + } + } + + /// `file`'s session as it was left, while it is still kept loaded ([`PARK_FOR`]). + pub(super) fn unpark(&mut self, file: &str) -> Option<(Server, super::Log)> { + let p = self.files.get_mut(file)?.parked.take()?; + (p.at.elapsed() <= PARK_FOR).then_some((p.server, p.log)) + } + /// What the server says about its hosting (with `file`'s expiry). pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo { let h = &self.cfg.hosting; diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs index 5f8904fc..efab8ec7 100644 --- a/apps/figcraft-server/src/store/journal.rs +++ b/apps/figcraft-server/src/store/journal.rs @@ -44,8 +44,13 @@ impl Store { } /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). + /// One everyone left lately is still loaded: it comes back as it was, nothing read (#1074). pub(super) fn load(&mut self, file: &str) -> Result { self.touch(file); + if let Some((server, log)) = self.unpark(file) { + self.entry(file)?.log = Some(log); + return Ok(server); + } let dir = self.dir(file); let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; @@ -258,6 +263,7 @@ pub(super) fn flush_log(id: &str, log: &mut Log) -> Result { if log.pending.is_empty() { return Ok(0); } + disk::counted(true); match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { Ok(()) => { let n = log.pending.len() as u64; diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 384e3579..90e76c69 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -15,9 +15,11 @@ path = "src/main.rs" [dependencies] figcraft-engine = { workspace = true } +figcraft-format = { workspace = true } figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-text = { workspace = true } +base64 = { workspace = true } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } @@ -26,7 +28,6 @@ serde_json = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" -base64 = { workspace = true } js-sys = "0.3" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] } diff --git a/apps/figcraft-web/index.html b/apps/figcraft-web/index.html index 1915a9a2..7bcf5d38 100644 --- a/apps/figcraft-web/index.html +++ b/apps/figcraft-web/index.html @@ -10,9 +10,13 @@ #figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; } #figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; } + /* Shown once the page stopped on an internal error (web.rs: show_stopped). */ + #figcraft_stopped { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; + padding: 16px; text-align: center; color: #1e1e1e; background: rgba(245, 245, 245, 0.92); font: 14px/1.5 system-ui, sans-serif; } @media (prefers-color-scheme: dark) { html, body { background: #2c2c2c; } #figcraft_loading { color: #b8b8b8; } + #figcraft_stopped { color: #f0f0f0; background: rgba(44, 44, 44, 0.92); } } diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index f9c0fb0a..4f38d787 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -9,6 +9,8 @@ //! - no native file dialogs yet: the session starts with one new document; //! - links open in a new browser tab; //! - of the preferences, the page keeps only your name across reloads (`kept.rs`); +//! - an internal error (a panic) can't be caught, so it stops the page: the files with unsaved +//! changes are kept in the page's storage and open again on the next reload (`recovery.rs`); //! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this //! computer…", Chromium's Local Font Access) the computer's, each read when text uses it //! (`local_fonts.rs`); @@ -37,6 +39,10 @@ mod query; #[cfg(test)] mod query_tests; #[cfg(any(target_arch = "wasm32", test))] +mod recovery; +#[cfg(test)] +mod recovery_tests; +#[cfg(any(target_arch = "wasm32", test))] mod saved_files; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/recovery.rs b/apps/figcraft-web/src/recovery.rs new file mode 100644 index 00000000..04d19403 --- /dev/null +++ b/apps/figcraft-web/src/recovery.rs @@ -0,0 +1,85 @@ +//! Recovery copies in the browser. The browser build can't catch a panic (wasm32 always aborts), +//! so a command or frame that panics ends the page instead of failing as on the desktop. The page +//! follows its files with unsaved changes after every frame ([`Unsaved::follow`]: cheap shared +//! copies, taken only when a file changed); the panic hook writes them to the page's local storage +//! ([`store`]) before the page stops, and the next page opens them again as unsaved files +//! ([`restore`], `file.recover`), as the desktop app does with its recovery copies. + +use std::sync::Arc; + +use base64::Engine as _; +use figcraft_engine::branches::{needs_copy, recovery_copy, stamp}; +use figcraft_engine::doc::Document; +use figcraft_engine::{DocState, Session}; +use figcraft_format::{Branch, Version}; +use serde_json::json; + +/// A file with unsaved changes as of the last frame. +struct Copy { + uid: u64, + /// [`stamp`]: the revisions this copy is of. + stamp: Vec, + doc: Arc, + versions: Vec, + branches: Vec, +} + +impl Copy { + fn of(s: &Session, d: &DocState) -> Self { + let (doc, versions, branches) = recovery_copy(s, d); + Self { uid: d.uid, stamp: stamp(s, d), doc, versions, branches } + } +} + +/// The page's files with unsaved changes, as of the last frame. +#[derive(Default)] +pub struct Unsaved(Vec); + +impl Unsaved { + /// Catch up with `s`: a file that changed is taken again, the others are kept as they are. + pub fn follow(&mut self, s: &Session) { + let mut before = std::mem::take(&mut self.0); + for d in s.docs.iter().filter(|d| needs_copy(s, d)) { + let now = stamp(s, d); + let copy = match before.iter().position(|c| c.uid == d.uid && c.stamp == now) { + Some(i) => before.swap_remove(i), + None => Copy::of(s, d), + }; + self.0.push(copy); + } + } + + /// How many files have unsaved changes. + pub fn count(&self) -> usize { + self.0.len() + } + + /// Each file as a saved `.figcraft` file in base64 (one that can't be written is left out). + pub fn files(&self) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD; + self.0 + .iter() + .filter_map(|c| figcraft_format::save_bytes_with_history(&c.doc, None, &c.versions, &c.branches).ok().map(|b| b64.encode(b))) + .collect() + } +} + +/// Keep as many of `files` as the storage takes, in order: `put` stores the list's JSON and says +/// whether it fitted (a refused write leaves what was stored before). Returns how many were kept. +pub fn store(files: Vec, mut put: impl FnMut(&str) -> bool) -> usize { + let mut kept = Vec::new(); + for f in files { + kept.push(f); + if !put(&json!(kept).to_string()) { + kept.pop(); + } + } + kept.len() +} + +/// Open the copies the last page kept (`kept`: what [`store`] stored, if anything) as files with +/// unsaved changes. Returns how many came back; junk in the storage is skipped. +pub fn restore(s: &mut Session, kept: Option) -> usize { + let files: Vec = kept.and_then(|k| serde_json::from_str(&k).ok()).unwrap_or_default(); + files.iter().filter(|f| s.execute("file.recover", &json!({"data": f})).is_ok()).count() +} diff --git a/apps/figcraft-web/src/recovery_tests.rs b/apps/figcraft-web/src/recovery_tests.rs new file mode 100644 index 00000000..f66a1571 --- /dev/null +++ b/apps/figcraft-web/src/recovery_tests.rs @@ -0,0 +1,115 @@ +//! Recovery copies in the browser: what a page kept when it stopped opens again, unsaved, on the +//! next page. + +use figcraft_engine::Session; +use figcraft_ui_egui::browser_stopped; +use serde_json::json; + +use crate::recovery::{Unsaved, restore, store}; + +fn edited(name: &str) -> Session { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("file.rename", &json!({"name": name})).unwrap(); + s +} + +/// The page's copies, stored as the panic hook stores them. +fn stored(u: &Unsaved) -> Option { + let mut out = None; + let kept = store(u.files(), |v| { + out = Some(v.to_string()); + true + }); + assert_eq!(kept, u.count()); + out +} + +#[test] +fn a_stopped_pages_unsaved_files_open_again_unsaved() { + let s = edited("Poster"); + let mut u = Unsaved::default(); + u.follow(&s); + assert_eq!(u.count(), 1); + let mut next = Session::new(); + let before = next.docs.len(); + assert_eq!(restore(&mut next, stored(&u)), 1); + assert_eq!(next.docs.len(), before + 1); + let d = next.ds().unwrap(); + assert_eq!(d.doc.name, "Poster"); + assert!(d.is_dirty(), "a recovered file has unsaved changes"); + assert!(d.path.is_none(), "saving it asks where"); +} + +#[test] +fn the_latest_edit_is_kept_and_a_saved_file_is_not() { + let mut s = edited("First"); + let mut u = Unsaved::default(); + u.follow(&s); + u.follow(&s); + assert_eq!(u.count(), 1, "following again without a change keeps the one copy"); + s.execute("file.rename", &json!({"name": "Later"})).unwrap(); + u.follow(&s); + let mut next = Session::new(); + assert_eq!(restore(&mut next, stored(&u)), 1); + assert_eq!(next.ds().unwrap().doc.name, "Later"); + s.execute("file.markSaved", &json!({})).unwrap(); + u.follow(&s); + assert_eq!(u.count(), 0); + assert!(u.files().is_empty()); +} + +#[test] +fn what_the_storage_has_no_room_for_is_left_out() { + let files = vec!["a".repeat(10), "b".repeat(100), "c".repeat(10)]; + let mut kept = String::new(); + let n = store(files, |v| { + let fits = v.len() < 60; + if fits { + kept = v.to_string(); + } + fits + }); + assert_eq!(n, 2); + assert_eq!(kept, json!(["a".repeat(10), "c".repeat(10)]).to_string()); + assert_eq!(store(vec!["x".into()], |_| false), 0); +} + +#[test] +fn junk_in_the_storage_opens_nothing() { + let mut s = Session::new(); + let before = s.docs.len(); + for junk in [None, Some("not json"), Some("{}"), Some(r#"["!!"]"#), Some(r#"["aGVsbG8="]"#), Some("[1, null]")] { + assert_eq!(restore(&mut s, junk.map(String::from)), 0, "{junk:?}"); + } + assert_eq!(s.docs.len(), before); +} + +#[test] +fn the_stopped_page_says_what_was_kept() { + let says = |unsaved, kept, what: &str| { + let text = browser_stopped(unsaved, kept); + assert!(text.starts_with("FigCraft stopped because of an internal error. ") && text.contains(what), "{text}"); + }; + says(0, 0, "start again"); + says(2, 2, "get your unsaved files back"); + says(2, 0, "too large for the browser to keep"); + says(3, 1, "the browser had room for"); +} + +#[test] +fn a_copy_taken_while_a_version_is_previewed_is_of_the_file() { + let mut s = edited("Poster"); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("file.rename", &json!({"name": "Later"})).unwrap(); + // Version v1 (named Poster) shows in place of the file. + s.execute("version.preview", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().name, "Poster", "the preview shows"); + let mut u = Unsaved::default(); + u.follow(&s); + let mut next = Session::new(); + assert_eq!(restore(&mut next, stored(&u)), 1); + let d = next.ds().unwrap(); + assert_eq!(d.doc.name, "Later", "the file comes back, not the version shown"); + assert_eq!(d.versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1"]); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index a3e4d930..e8e7f54a 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -6,11 +6,23 @@ use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; const LOADING_ID: &str = "figcraft_loading"; +/// What the page shows once it stopped on an internal error (styled in `index.html`). +const STOPPED_ID: &str = "figcraft_stopped"; /// The local storage key your name is kept under ([`crate::kept`]). const NAME_KEY: &str = "figcraft.userName"; +/// The local storage key the recovery copies are kept under ([`crate::recovery`]): a JSON list of +/// base64 `.figcraft` files. +const RECOVERY_KEY: &str = "figcraft.recovery"; + +thread_local! { + /// The files with unsaved changes as of the last frame, for the panic hook ([`crate::recovery`]). + static UNSAVED: std::cell::RefCell = std::cell::RefCell::default(); +} pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + // Before the runner's own hook, which logs the panic and then calls this one. + keep_unsaved_on_panic(); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { log::error!("no document"); @@ -39,11 +51,13 @@ pub fn start() { let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); let mut session = Session::new(); crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten())); + let recovered = restore_unsaved(&mut session); // Live sessions: the browser joins sessions and publishes files to servers; // whatever arrives on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + fig.note_recovered(recovered); // `#join=`: join that session, as opening an invite link does on the desktop. let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); if let Some(link) = crate::query::join_link(&hash) { @@ -70,6 +84,41 @@ pub fn start() { }); } +/// The browser can't catch a panic (wasm32 aborts), so the page ends with it: keep the files with +/// unsaved changes in the page's storage for the next page, and say so on the page. +fn keep_unsaved_on_panic() { + static ONCE: std::sync::Once = std::sync::Once::new(); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + previous(info); + // Callbacks that run after the page stopped can panic again: the files are kept once. + ONCE.call_once(|| { + let (unsaved, files) = UNSAVED.with(|u| u.try_borrow().map(|u| (u.count(), u.files())).unwrap_or_default()); + let kept = local_storage().map_or(0, |s| crate::recovery::store(files, |v| s.set_item(RECOVERY_KEY, v).is_ok())); + show_stopped(&figcraft_ui_egui::browser_stopped(unsaved, kept)); + }); + })); +} + +/// Open the files the last page kept when it stopped; how many came back. +fn restore_unsaved(session: &mut Session) -> usize { + let Some(storage) = local_storage() else { return 0 }; + let kept = storage.get_item(RECOVERY_KEY).ok().flatten(); + // Gone before they open: a copy that stops the page again must not come back on every reload + // (once a frame ran, the files it opened are followed as any unsaved file). + let _ = storage.remove_item(RECOVERY_KEY); + crate::recovery::restore(session, kept) +} + +/// Say on the page that it stopped (the canvas stays as it was). +fn show_stopped(text: &str) { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let (Ok(el), Some(body)) = (document.create_element("div"), document.body()) else { return }; + el.set_id(STOPPED_ID); + el.set_text_content(Some(text)); + let _ = body.append_child(&el); +} + /// The page's local storage (`None` where the browser keeps none, e.g. storage turned off). fn local_storage() -> Option { web_sys::window().and_then(|w| w.local_storage().ok().flatten()) @@ -90,6 +139,8 @@ impl eframe::App for WebShell { /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does /// not repeat it. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Every frame, hidden or not, starts here: what the last one left is what a panic keeps. + UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok(); figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 605a04ca..750786d1 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -25,6 +25,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; +use figcraft_engine::branches::{needs_copy, recovery_copy, stamp}; use figcraft_engine::{DocState, Session}; use figcraft_format::{Branch, Version}; use serde_json::json; @@ -203,8 +204,7 @@ impl Recovery { if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() { continue; } - let doc = d.doc.clone(); - let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); + let (doc, versions, branches) = recovery_copy(s, d); // Which online file it is and what of the server's this very copy has (a line each: // the server, the file's id, then the session, client id, last change and the secret the // client id resumes with), while it @@ -321,23 +321,6 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } -/// A file whose changes would be lost: in its document, its history or one of its branch tabs -/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own). -fn needs_copy(s: &Session, d: &DocState) -> bool { - d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty)) -} - -/// The tabs showing branches of the file in tab `uid`. -fn branch_tabs(s: &Session, uid: u64) -> impl Iterator { - s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid)) -} - -/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change -/// only its own revision). A new copy is due when they change. -fn stamp(s: &Session, d: &DocState) -> Vec { - std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect() -} - /// Write `doc` with its version history and branches to `path` through a temporary file; false /// when it couldn't be written (the copy before it is still there). fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 0322e237..2eda9a94 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -388,3 +388,21 @@ fn a_background_copy_that_failed_is_tried_again_without_another_edit() { r.finish(); let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn a_copy_made_while_a_version_is_previewed_is_of_the_file_not_the_version() { + let root = root("preview"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + // The one-rectangle version shows in place of the two-rectangle file. + s.execute("version.preview", &json!({"index": 0})).unwrap(); + assert_eq!(rectangles(s.doc().unwrap()), 1, "the preview shows"); + assert!(s.ds().unwrap().is_dirty(), "the file itself still has unsaved changes"); + let mut r = Recovery::start(&root).unwrap(); + r.write(&s, true); + let next = crash_and_recover(r, &root); + assert_eq!(rectangles(next.doc().unwrap()), 2, "the file comes back, not the version shown"); + assert_eq!(next.docs[0].versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1"]); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/collab-net/src/exchange.rs b/crates/collab-net/src/exchange.rs index a01e9d59..b0d103ae 100644 --- a/crates/collab-net/src/exchange.rs +++ b/crates/collab-net/src/exchange.rs @@ -53,9 +53,7 @@ impl Exchange { /// `file` with its `owner` token. pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec) -> Self { let open = match key { - Some(key) => { - ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() }) - } + Some(key) => ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() }), None => ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), @@ -144,7 +142,7 @@ pub(crate) fn publish(job: Publish) -> Next { // The file's first links stay its owner's (this app joins with one): others get their // own, which stopping takes away again. let owner = published.owner.unwrap_or_default(); - let then: Box, String>) -> Next> = Box::new(move |made| { + let then: LinksThen = Box::new(move |made| { events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string())))); Next::Done(events, Some(edit)) }); @@ -163,26 +161,45 @@ struct Sharing { create: bool, } -/// Revoke the links `s.revoke` names, then (to create) make new ones and ask for their codes on a -/// connection of their own; `then` gets the links. -fn share_links(s: Sharing, then: Box, String>) -> Next>) -> Next { +/// What [`share_links`] hands the links to. +type LinksThen = Box, String>) -> Next>; + +/// Revoke the links `s.revoke` names, then (to create) make new ones, then a comment link and their +/// codes, each on a connection of its own; `then` gets the links. +fn share_links(s: Sharing, then: LinksThen) -> Next { let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create)); Next::Ask( Box::new(ex), - Box::new(move |answers| { - let links = match answers.and_then(|a| requests::made_links(a.replies, s.create)) { - Ok(Some(links)) => links, - other => return then(other.map(|_| None)), - }; - let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links)); - Next::Ask( - Box::new(ex), - Box::new(move |codes| { - // A server from before join codes: the links are shared without them. - let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default(); - then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies)))) - }), - ) + Box::new(move |answers| match answers.and_then(|a| requests::made_links(a.replies, s.create)) { + Ok(Some(links)) => comment_link(s, links, then), + other => then(other.map(|_| None)), + }), + ) +} + +/// Ask for a comment link beside `links`, then for their codes. +fn comment_link(s: Sharing, mut links: requests::Links, then: LinksThen) -> Next { + let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::comment_request(&s.file)); + Next::Ask( + Box::new(ex), + Box::new(move |made| { + // A server from before comment links: the file is shared without one. + let made = made.map_err(|e| log::info!("live session: no comment link from {}: {e}", s.server.display())); + links.comment = made.ok().and_then(|a| requests::made_comment(a.replies)); + codes(s, links, then) + }), + ) +} + +/// Ask for the codes of `links`; `then` gets them with their codes. +fn codes(s: Sharing, links: requests::Links, then: LinksThen) -> Next { + let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links)); + Next::Ask( + Box::new(ex), + Box::new(move |codes| { + // A server from before join codes: the links are shared without them. + let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default(); + then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies)))) }), ) } diff --git a/crates/collab-net/src/exchange_tests.rs b/crates/collab-net/src/exchange_tests.rs index 0e38039a..85274e41 100644 --- a/crates/collab-net/src/exchange_tests.rs +++ b/crates/collab-net/src/exchange_tests.rs @@ -129,6 +129,11 @@ fn make_links(d: &mut Driver