diff --git a/Cargo.lock b/Cargo.lock index bc2dcbc1..7191353e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1115,13 +1115,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", diff --git a/Cargo.toml b/Cargo.toml index 0dda1967..2bef2eb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,8 @@ figcraft-mcp = { path = "crates/mcp" } figcraft-collab-net = { path = "crates/collab-net" } figcraft-plugin = { path = "crates/plugin" } -# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. +# Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate +# dependencies and is pinned separately for the suite updater rollout. # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the @@ -41,7 +42,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } # The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto # indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs). craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/README.md b/README.md index 75328de1..f6e3d769 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,13 @@

Components and variants: component sets, variant properties and the Assets panel · examples/ridgeline-design-system.figcraft

- Four onboarding phone screens in the Prototype tab, joined by blue connection curves from the Get started, Continue, Allow location and Not now buttons to the next screens; the Get started button is selected and the Prototype panel shows its On click, Navigate to 2 Interests interaction + Four onboarding phone screens in the Prototype tab, joined by green connection curves from the Get started, Continue, Allow location and Not now buttons to the next screens; the Get started button is selected and the Prototype panel shows its On click, Navigate to 2 Interests interaction

Prototyping: connections, transitions, back actions and a flow · examples/ridgeline-onboarding.figcraft

- Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows generated React code with a TrailRow props interface and the instance's title and details values + Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows a Playground preview of it and generated React code with a TrailRow props interface and the instance's title and details values

Dev Mode: ready-for-dev status, annotations and React code where component properties become props · examples/ridgeline-app.figcraft

diff --git a/ROADMAP.md b/ROADMAP.md index 468f2763..ed14cbfe 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-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error 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 @@ -356,6 +356,15 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | +| 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. | +| 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. | +| 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | +| 2026-10-30 | minor | M8, #1097: while the Assets panel showed a file, the app asked the server of every team library the file named for updates, with the link's token and a usage report, so a file someone sent could make the app contact any server. Now only team libraries you added (`library.add`) or checked on this computer are checked by themselves (kept with the preferences as hashes of their links); another one waits in **Assets ▸ Libraries** as "Not checked yet" until you click **Check for updates** (`library.check`, which plugins can't call). Placing or applying something from it is your own action and asks its server as before. Numbers unchanged. Tests: `library_beacon_tests` (engine and UI). | +| 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. | +| 2026-10-30 | minor | M8, #1117: `doc.list` returned the path on disk of every open file to any caller, plugins and attached AI agents included. It now gives an agent's calls (MCP, or a control call naming an `agent`) and plugins each file's `id`, name and state without `path`; the user's own scripts (the CLI, control calls without an agent) still get paths, and the app's UI reads its tabs directly. Numbers unchanged. Tests: `doc_list_paths_tests`. | +| 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. | +| 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). | +| 2026-10-30 | minor | M8, #1099: `command.batch` (and every grouped run: plugins, scripts, MCP `batch`) kept a full undo snapshot per step until the batch ended, so a long batch held one document copy per step. Inside a group, edits now add to the one undo step the group pushed, so a batch holds one snapshot however long it is; it is still one undo step that restores exactly the state before it, and a failure still rolls everything back. An `edit.undo` step inside a batch now undoes the batch's edits so far together. Measured on a 2,000-layer file with 160 KB of document colours (debug build): a 1,000-step batch went from +165 MiB peak and 80 to 100 ms to +10 MiB and about 50 ms (4,000 steps: +35 MiB, the step list and command journal). Numbers unchanged. Tests: `batch_undo_snapshot_tests`. | | 2026-10-30 | minor | M8, #1101: the importers' 256-level depth cap could overflow the browser's 1 MB stack before it was reached. Measured per level: the REST import's layer walk (also the second stage of every `.fig` import) took about 4 KB, a whole megabyte at 256, and overflowed a 1 MB thread; it now runs on a work list in the same order (groups fitted once their children are in), a few KB in all. The `.fig` mapping, which builds each layer from its children's results, stays recursive at about 1.8 KB a level, 455 KB at the cap. Desktop and browser keep the 256-level cap, so nothing that loads today is cut off. Numbers unchanged. Tests: `deep_import_tests` (256 and 400 levels on a 1 MB thread). | | 2026-10-30 | minor | M8, #1082 (the rest): the SVG and PDF exporters drew a pattern fill's source from depth 0 again, and the renders they embed (a layer with effects drawn alone, a pattern cell with effects) started at 0 too, so an export could nest its own levels, the renderer's and a pattern's on top of each other. A pattern source now exports one level below the layer it fills, and what the renderer draws for a layer goes on from that layer's depth (`figcraft_render::at_layer_depth`), so one cap bounds the whole export; the render of what lies beneath a layer (background blur, glass) is of the page's own layers and still counts from the page. Canvas masks, mask content and boolean and outline walks already carried the depth or have their own small caps. Numbers unchanged. Tests: `render_depth_tests`, `figcraft-svg` and `figcraft-pdf` `pattern_depth_tests`. | | 2026-10-30 | minor | M8, #1082: a pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels; its source now goes on from the depth of the layer it fills, and the one cap bounds the whole render. Measured, a level takes about 6.5 KB of stack in a render (6.8 KB in a debug build), so the deepest allowed render needs about 1.7 MB: the canvas render threads, the save thread (it renders the thumbnail) and a band pool of their own now get 8 MiB stacks, like the desktop app's main thread (rayon's and other threads' default is 2 MiB). Numbers unchanged. Tests: `render_depth_tests` (the cap reached through a pattern; three 250-level chains of patterns in patterns rendered on a render thread's stack). | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 7b822646..8614e2cd 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -402,8 +402,12 @@ pub fn parse_script(text: &str) -> Result, String> { .collect() } +/// The largest script `run` reads (one may carry images as base64): a device, a pipe or a larger +/// file is refused before it is read (#1114). +pub const MAX_SCRIPT: u64 = 1 << 30; + pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; + let bytes = figcraft_engine::file_access::read_capped(script, MAX_SCRIPT).map_err(|e| CliError::Failed(e.to_string()))?; let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let (mut s, input_images_left_out) = match input { @@ -538,6 +542,21 @@ mod tests { dir } + /// A script path that is a folder or a device is refused before anything is read (#1114). + #[test] + fn a_script_that_isnt_a_regular_file_is_refused() { + let dir = temp_dir("script-caps"); + let mut bad = vec![dir.to_string_lossy().into_owned()]; + if cfg!(unix) { + bad.push("/dev/zero".into()); + } + for path in bad { + let Err(CliError::Failed(e)) = run_script(&path, None, None) else { panic!("{path} ran") }; + assert!(e.contains("is a folder, not a file") || e.contains("isn't a regular file"), "{path}: {e}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it /// as "no parent" and the layer landed on the page — with every step reporting success. diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index 05eb5122..386c397f 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -27,7 +27,9 @@ pub const MAX_ADDRESSES: usize = 10_000; pub const REPORTS_PER_HOUR: f64 = 10.0; /// Bytes one address may download a second (team libraries, plugins), and at once: an IPv6 /48 -/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once). +/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once). Larger +/// than the burst, a library or plugin is served once the address's budget is full, which it then +/// owes ([`Bucket::take_spread`]): it comes more slowly, never not at all. pub const DOWNLOAD_RATE: f64 = (1u64 << 20) as f64; pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64; @@ -165,7 +167,7 @@ impl Abuse { } for &(k, times) in &keys { let (b, n) = Self::pick(self.address(k, times, now), what); - b.take_n(now, n); + b.take_spread(now, n); } true } @@ -211,7 +213,7 @@ impl Abuse { /// The block has `what`'s tokens now (nothing is spent). fn room(&mut self, ip: Block, times: f64, what: Action, now: Instant) -> bool { let (b, n) = Self::pick(self.address(ip, times, now), what); - if b.take_n(now, n) { + if b.take_spread(now, n) { b.give(n); return true; } diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index 01ef3cbd..e2c48e05 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -147,6 +147,14 @@ impl Config { client_max - 1 )); } + if self.people() < self.max_people { + out.push(format!( + "max_people is {}: the app keeps track of at most {} people in a file, so {} are let in", + self.max_people, + self.people(), + self.people() + )); + } if !self.open_publish { return out; } @@ -171,6 +179,12 @@ impl Config { out } + /// Most people in one file at once: `max_people`, lowered to what the app keeps track of + /// ([`figcraft_collab::message::MAX_PEERS`]). + pub fn people(&self) -> usize { + self.max_people.min(figcraft_collab::message::MAX_PEERS) + } + /// The trusted proxy's address, when set. pub fn proxy(&self) -> Result, String> { let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) }; @@ -284,8 +298,7 @@ impl Config { /// most, and those batches also at most a quarter of a file's bytes (256 KiB at least). fn core(&self) -> figcraft_collab::Limits { let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX); - let base = - figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() }; + let base = figcraft_collab::Limits { max_peers: self.people(), max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() }; if !self.open_publish { return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } diff --git a/apps/figcraft-server/src/download_burst_tests.rs b/apps/figcraft-server/src/download_burst_tests.rs new file mode 100644 index 00000000..61f36730 --- /dev/null +++ b/apps/figcraft-server/src/download_burst_tests.rs @@ -0,0 +1,67 @@ +//! A team library or plugin larger than one address's download burst (16 MiB) is served once the +//! address's budget is full, which it then owes: it comes more slowly, never not at all, and the +//! address still gets no more than its rate over time (#1089). + +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{Access, LibraryReply, LibraryReq}; +use figcraft_collab_net::hub::Backend; +use figcraft_collab_net::net::Bucket; + +use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST, DOWNLOAD_RATE}; +use crate::hardening_tests::archive; +use crate::store::{BUSY_DOWNLOADS, Store}; +use crate::test_util::{TempDir, config, ip, snapshot}; + +const MIB: usize = 1 << 20; + +fn secs(s: u64) -> Duration { + Duration::from_secs(s) +} + +#[test] +fn a_bucket_lends_past_its_burst_only_when_full_and_is_paid_back_at_its_rate() { + let mut b = Bucket::new(1.0, 4.0); + let t = Instant::now(); + assert!(b.take_n(t, 1.0)); + assert!(!b.take_spread(t, 10.0), "not full: no loan"); + assert!(b.take_spread(t + secs(1), 10.0), "full again: lent, owing 6"); + assert!(!b.take_spread(t + secs(6), 1.0), "still owing 1"); + assert!(!b.take_spread(t + secs(10), 10.0), "3 of 4 back"); + assert!(b.take_spread(t + secs(11), 10.0), "paid back: lent again"); + assert!(b.take_spread(t + secs(21), 2.0), "within the burst as before"); +} + +#[test] +fn large_downloads_keep_an_address_to_its_rate() { + let mut a = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 }); + let t = Instant::now(); + let me = ip("198.51.100.1"); + let big = 40 * MIB; + // Asking for 40 MiB every second for ten minutes. + let mut got = Vec::new(); + for s in 0..=600 { + if a.allow(me, Action::Download(big), t + secs(s)) { + got.push(s); + } + } + let bytes = |within: u64| (got.iter().filter(|s| **s < within).count() * big) as f64; + for minutes in 1..=10 { + let most = big as f64 + DOWNLOAD_RATE * (minutes * 60) as f64; + assert!(bytes(minutes * 60) <= most, "{} MiB in {minutes} min", bytes(minutes * 60) / MIB as f64); + } + assert!(bytes(601) >= DOWNLOAD_RATE * 600.0, "it is served at the rate, not refused: {got:?}"); + assert!(big as f64 > DOWNLOAD_BURST); +} + +#[test] +fn a_library_larger_than_the_burst_is_downloaded_then_the_address_waits() { + let dir = TempDir::new("biglib"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + store.publish_library(&file.id, &archive(0, 40 * MIB)).unwrap(); + let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None }); + assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Library { archive, .. } if archive.len() > 40 * MIB)); + assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Failed { reason } if reason == BUSY_DOWNLOADS), "then it waits"); + assert!(matches!(fetch(&mut store, "198.51.100.9"), LibraryReply::Library { .. }), "another address has its own share"); +} diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 8226aa3f..be28d22d 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -33,7 +33,7 @@ fn open(dir: &std::path::Path) -> Config { /// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot /// (deflated: a few KiB) and `padding` incompressible bytes stored beside it. -fn archive(bomb: usize, padding: usize) -> Vec { +pub(crate) fn archive(bomb: usize, padding: usize) -> Vec { let mut s = Session::new(); let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap(); s.execute("component.create", &json!({"ids": [f]})).unwrap(); diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index c488d19f..575cf79c 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -99,6 +99,8 @@ mod comment_link_tests; #[cfg(test)] mod directory_tests; #[cfg(test)] +mod download_burst_tests; +#[cfg(test)] mod hardening_tests; #[cfg(test)] mod idle_io_tests; diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index b63f0993..be69b030 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -481,6 +481,13 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() { assert_eq!(set.file_mb(), 8); assert!(Config { trusted_proxy: Some("localhost".into()), ..set.clone() }.check().is_err()); assert!(Config { terms_url: "javascript:alert(1)".into(), ..set.clone() }.check().is_err()); + // Clients keep track of at most `MAX_PEERS` others (#1121). + let people = figcraft_collab::message::MAX_PEERS; + let many = Config { max_people: people + 1, ..set.clone() }; + assert!(many.check().is_ok(), "a server configured for more still starts"); + assert_eq!(many.people(), people, "with that many"); + assert!(many.warnings().iter().any(|w| w.contains("max_people is 4097") && w.contains("4096 are let in")), "{:?}", many.warnings()); + assert_eq!(Config { max_people: people, ..set.clone() }.people(), people); assert!(Config { idle_days: f64::NAN, ..set }.check().is_err()); } diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index 91bdc3ac..c7aa07e1 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -1,7 +1,7 @@ use super::journal::flush_log; use super::{ Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, - LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, + LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret, }; /// Whether `req` changes the server's files (a write, a removal or a join code's stretching): @@ -105,7 +105,8 @@ impl Backend for Store { } fn manage(&mut self, key: &str) -> Result<(), String> { - match same_secret(key, &self.key) { + // Both sides are SHA-256 digests, so the comparison sees equal lengths whatever was sent. + match same_secret(&hash(key), &hash(&self.key)) { true => Ok(()), false if self.cfg.hosting.open => { Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs index 55ca50ad..2e59e821 100644 --- a/apps/figcraft-server/src/store/library.rs +++ b/apps/figcraft-server/src/store/library.rs @@ -16,7 +16,7 @@ impl Store { let path = library_file(&dir, lib.version); let size = fs::metadata(&path).map_or(0, |m| m.len()); let now = Instant::now(); - if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { + if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_spread(now, size as f64) { return Err(BUSY_DOWNLOADS.into()); } Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index 93a872ee..f94e76f0 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -257,6 +257,46 @@ fn one_server_per_data_directory() { assert_eq!(again.key(), key, "the key is kept"); } +#[test] +fn operator_key_authentication() { + let key = "operator-key-0123456789abcdef"; + for open in [false, true] { + let dir = TempDir::new("operator-key"); + fs::write(dir.path().join(crate::store::KEY), format!("{key}\n")).unwrap(); + let mut c = cfg(dir.path()); + c.hosting.open = open; + let mut store = Store::open(dir.path(), c).unwrap(); + let refusal = match open { + true => "wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)", + false => "wrong server key", + }; + assert_eq!(store.manage(key), Ok(())); + let same_length: String = key.chars().rev().collect(); + for wrong in [same_length.as_str(), "", &key[..key.len() - 1], &format!("{key}x"), &format!("{key}\n")] { + assert_eq!(store.manage(wrong), Err(refusal.to_string()), "{wrong:?} is refused"); + } + } +} + +#[test] +fn operator_key_rotation_survives_restart() { + let dir = TempDir::new("operator-key-rotation"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let old = store.key().to_string(); + assert_eq!(store.manage(&old), Ok(())); + let new = store.rotate_key().unwrap(); + assert_ne!(new, old); + assert_eq!(store.key(), new); + assert!(store.manage(&old).is_err(), "the old key stops working"); + assert_eq!(store.manage(&new), Ok(())); + drop(store); + let mut again = Store::open(dir.path(), cfg(dir.path())).unwrap(); + assert_eq!(again.key(), new); + assert!(again.manage(&old).is_err(), "the old key stays refused after a restart"); + assert_eq!(again.manage(&new), Ok(())); + assert_eq!(fs::read_to_string(dir.path().join(crate::store::KEY)).unwrap(), format!("{new}\n"), "the key file keeps the raw key"); +} + #[cfg(unix)] #[test] fn an_existing_data_directory_is_made_private() { diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 22c84756..a9675da2 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -137,7 +137,7 @@ impl eframe::App for App { fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&figcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); diff --git a/contributors/people.toml b/contributors/people.toml index 77553d44..acab9bca 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -98,3 +98,6 @@ display_name = "Filip Strajnar" [people.Carbocode] sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/collab-net/src/client_cap_tests.rs b/crates/collab-net/src/client_cap_tests.rs new file mode 100644 index 00000000..920d5348 --- /dev/null +++ b/crates/collab-net/src/client_cap_tests.rs @@ -0,0 +1,61 @@ +//! A hostile server can't grow a guest's memory without bound (#1121): the presence waiting for +//! the UI and the people a connection has seen hold at most [`MAX_PEERS`] others; past it, +//! someone new is left out, and someone leaving (or the UI taking the presence) makes room. + +use std::sync::{Arc, Mutex}; + +use figcraft_collab::message::{Peer, Presence, ServerMsg}; +use figcraft_engine::live::{Event, MAX_PEERS}; + +use crate::guest::GuestConfig; +use crate::inbox::Inbox; +use crate::invite::Invite; +use crate::join::{Seen, take}; + +fn inbox() -> Inbox { + Inbox::new(Arc::new(|| {})) +} + +#[test] +fn presence_from_more_people_than_the_cap_is_left_out() { + let inbox = inbox(); + let over = MAX_PEERS as u32 + 500; + for id in 0..over { + inbox.presence(id, Presence::default()); + } + // Someone already waiting is still updated. + let moved = Presence { selection: Some(Vec::new()), ..Presence::default() }; + inbox.presence(3, moved.clone()); + let out = inbox.drain(); + assert_eq!(out.len(), MAX_PEERS); + assert!(out.iter().all(|e| matches!(e, Event::Presence(id, _) if (*id as usize) < MAX_PEERS))); + assert!(out.iter().any(|e| matches!(e, Event::Presence(3, p) if *p == moved))); + // Taken by the UI: room again. + inbox.presence(over, Presence::default()); + assert!(matches!(inbox.drain().as_slice(), [Event::Presence(id, _)] if *id == over)); +} + +#[test] +fn joins_past_the_cap_are_left_out_until_someone_leaves() { + let inbox = inbox(); + let cfg = GuestConfig::new(Invite::new("127.0.0.1:47900", "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv"), "Ada".into()); + let resume = Mutex::new(None); + let mut seen = Seen::default(); + let mut send = |m: ServerMsg| { + take(m, &cfg, &inbox, &mut seen, &resume, &|| {}); + }; + let joined = |id: u32| ServerMsg::PeerJoined(Peer { client_id: id, name: format!("P{id}"), color: String::new() }); + let over = MAX_PEERS as u32 + 500; + for id in 0..over { + send(joined(id)); + } + let count = |events: &[Event]| events.iter().filter(|e| matches!(e, Event::PeerJoined(_))).count(); + assert_eq!(count(&inbox.drain()), MAX_PEERS, "joins past the cap reach the UI"); + // Someone already here may be renamed; someone left out stays out until someone leaves. + send(joined(5)); + send(joined(over)); + assert!(matches!(inbox.drain().as_slice(), [Event::PeerJoined(p)] if p.client_id == 5)); + send(ServerMsg::PeerLeft { client_id: 7 }); + send(joined(over)); + assert!(matches!(inbox.drain().as_slice(), [Event::PeerLeft(7), Event::PeerJoined(p)] if p.client_id == over)); +} diff --git a/crates/collab-net/src/inbox.rs b/crates/collab-net/src/inbox.rs index 1ec00e67..860a80cf 100644 --- a/crates/collab-net/src/inbox.rs +++ b/crates/collab-net/src/inbox.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, VecDeque}; use std::sync::{Arc, Mutex, PoisonError}; -use figcraft_engine::live::{Event, Presence}; +use figcraft_engine::live::{Event, MAX_PEERS, Presence}; /// Wakes the UI (the desktop app passes egui's `request_repaint`). pub type Waker = Arc; @@ -23,7 +23,10 @@ struct State { syncs: VecDeque<(u64, Event)>, /// Every other event, capped at [`MAX_EVENTS`]. others: VecDeque<(u64, Event)>, + /// The latest presence of each peer, for at most [`MAX_PEERS`] peers. presence: BTreeMap, + /// A presence was left out since the last drain (warned once). + presence_full: bool, } /// One session's mailbox (a new session gets a new one, so a stopped session's threads can't @@ -64,9 +67,17 @@ impl Inbox { }); } - /// Peer `id`'s latest presence (replaces one not taken yet). + /// Peer `id`'s latest presence (replaces one not taken yet). Past [`MAX_PEERS`] peers waiting, + /// another peer's is left out. pub fn presence(&self, id: u32, p: Presence) { self.with(|s| { + if s.presence.len() >= MAX_PEERS && !s.presence.contains_key(&id) { + if !s.presence_full { + log::warn!("live session: presence of more than {MAX_PEERS} people; leaving the rest out"); + s.presence_full = true; + } + return; + } s.presence.insert(id, p); }); } @@ -75,6 +86,7 @@ impl Inbox { /// peer. pub fn drain(&self) -> Vec { let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner); + s.presence_full = false; let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable(); let mut others = std::mem::take(&mut s.others).into_iter().peekable(); let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len()); diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 43b0b7b8..29c477f7 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -7,7 +7,7 @@ use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg}; -use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; +use figcraft_engine::live::{Event, MAX_PEERS, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::Invite; @@ -194,7 +194,7 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se *r = Some(Resume { session, client_id, seq, secret }); } // The sync client opens the document (and fills in its name and pages). - let peers = peers.clone(); + let peers: Vec = peers.iter().take(MAX_PEERS).cloned().collect(); let inv = &cfg.invite; let info = SessionInfo { role: Role::Guest, @@ -224,7 +224,16 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() }); } _ => { - seen.peers.retain(|q| q.client_id != p.client_id); + match seen.peers.iter().position(|q| q.client_id == p.client_id) { + Some(i) => { + seen.peers.remove(i); + } + None if seen.peers.len() >= MAX_PEERS => { + log::warn!("live session: {MAX_PEERS} people already here; leaving out another"); + return Taken::Continue; + } + None => {} + } seen.peers.push(p.clone()); inbox.push(Event::PeerJoined(p)); } diff --git a/crates/collab-net/src/lib.rs b/crates/collab-net/src/lib.rs index 656c41b0..523fbda0 100644 --- a/crates/collab-net/src/lib.rs +++ b/crates/collab-net/src/lib.rs @@ -55,6 +55,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip}; #[cfg(target_arch = "wasm32")] pub use web::NetLink; +#[cfg(test)] +mod client_cap_tests; #[cfg(test)] mod code_tests; #[cfg(test)] diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index 43e56001..9c35b5d1 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -298,6 +298,21 @@ impl Bucket { true } + /// Like [`take_n`](Self::take_n), but more than the burst may be taken from a full bucket, + /// which it leaves in debt: nothing more until the rate has paid it back. Something larger + /// than the burst (a big download) is then never refused for good, and the average stays at + /// `rate`. + pub fn take_spread(&mut self, now: Instant, n: f64) -> bool { + if n <= self.burst { + return self.take_n(now, n); + } + if !self.take_n(now, self.burst) { + return false; + } + self.tokens -= n - self.burst; + true + } + /// Give back `n` tokens (up to the burst). pub fn give(&mut self, n: f64) { self.tokens = (self.tokens + n).min(self.burst); diff --git a/crates/collab/src/deleted_cap_tests.rs b/crates/collab/src/deleted_cap_tests.rs new file mode 100644 index 00000000..14802438 --- /dev/null +++ b/crates/collab/src/deleted_cap_tests.rs @@ -0,0 +1,50 @@ +//! A session remembers at most [`MAX_DELETED`] deleted node ids for undo (#1121): a long session +//! deleting layer after layer, or a saved state listing more, keeps the highest ids and forgets the +//! lowest. + +use std::collections::BTreeSet; + +use figcraft_doc::{Document, Node, NodeId, NodeKind}; + +use crate::message::ClientMsg; +use crate::server::MAX_DELETED; +use crate::{Client, Limits, Outgoing, Server}; + +fn filled() -> BTreeSet { + (1..=MAX_DELETED as u64 + 10).map(NodeId).collect() +} + +#[test] +fn a_restored_state_keeps_the_highest_deleted_ids() { + let mut d = Document::new("Shared"); + d.repair(); + let mut state = Server::new(d, "tok", Limits::default()).state().unwrap(); + state.deleted = filled(); + let kept = Server::restore(state, "tok", Limits::default()).unwrap().state().unwrap().deleted; + assert_eq!(kept.len(), MAX_DELETED); + assert_eq!(kept.first(), Some(&NodeId(11)), "the lowest went first"); +} + +#[test] +fn deleting_past_the_cap_forgets_the_lowest() { + let mut d = Document::new("Shared"); + let page = d.first_page().unwrap(); + // From an earlier session's id space, above every id in `filled`. + let frame = NodeId((7 << 32) | 1); + d.insert(Node::new(frame, NodeKind::Frame, "Frame", 100.0, 100.0), page, None).unwrap(); + let mut state = Server::new(d, "tok", Limits::default()).state().unwrap(); + state.deleted = filled(); + let mut s = Server::restore(state, "tok", Limits::default()).unwrap(); + let ClientMsg::Hello(hello) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") }; + let (id, out) = s.join(hello).unwrap(); + let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") }; + let mut c = Client::join(welcome).unwrap(); + let mut edited = c.document().clone(); + edited.remove(frame).unwrap(); + let msg = c.edit(&edited).unwrap().expect("a change"); + s.receive(id, msg); + let deleted = s.state().unwrap().deleted; + assert_eq!(deleted.len(), MAX_DELETED); + assert!(deleted.contains(&frame), "the newly deleted frame is remembered"); + assert!(!deleted.contains(&NodeId(11))); +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 51be86b4..fd0b9bcb 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -133,6 +133,8 @@ mod codec_tests; #[cfg(test)] mod comment_access_tests; #[cfg(test)] +mod deleted_cap_tests; +#[cfg(test)] mod diff_tests; #[cfg(test)] mod entry_ids_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index b3f92415..8cc37167 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -375,6 +375,11 @@ pub enum Admin { /// The most files one [`Admin::Owned`] asks about. pub const MAX_OWNED: usize = 200; +/// Most other people a client keeps track of in one session (their names, presence and chat). +/// A server holds far fewer (256 by default, a larger `max_people` lowered to this): past it, someone new is +/// left out, so a hostile server can't grow a client's memory without bound. +pub const MAX_PEERS: usize = 4_096; + /// A file its owner asked about ([`Admin::Owned`]). #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct OwnedFile { diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 38d87c2c..9977b230 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -64,7 +64,7 @@ pub struct Server { presence: BTreeMap, acked: BTreeMap, /// Deleted node ids: only these may be created again (by undo); new ids must come from the - /// creating client's id space. + /// creating client's id space. At most [`MAX_DELETED`]. deleted: BTreeSet, /// Removed document-level entries (styles, variables, collections, comments): these may be /// put back by anyone (undo); a new one must not have an id from another client's space. @@ -98,6 +98,11 @@ pub struct Server { /// first; a forgotten client joins as new). const MAX_REMEMBERED: usize = 65_536; +/// The most deleted node ids a session remembers for undo. Past it the lowest ids go first (the +/// earliest clients' first layers): undoing the delete of one of those is then refused, unless the +/// layer is in the undoing client's own id space, which it may always create. +pub(crate) const MAX_DELETED: usize = 1 << 18; + /// What a session server needs to carry on after a restart: the document, the sequence number, /// the client-id counter (so ids are never handed out twice), the clients that can reconnect, /// the deleted ids and removed entries that may be created again and the log of recent batches. @@ -178,6 +183,7 @@ impl Server { server.next_client = server.next_client.max(saved); server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect(); server.deleted = state.deleted; + server.forget_deleted(); server.removed = state.removed; let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq)); // History saved before the image checks may hold image entries they refuse. The snapshot @@ -251,6 +257,13 @@ impl Server { } } + /// Beyond [`MAX_DELETED`] deleted ids, forget the lowest. + fn forget_deleted(&mut self) { + while self.deleted.len() > MAX_DELETED { + self.deleted.pop_first(); + } + } + /// Beyond [`MAX_REMEMBERED`] client ids, forget the oldest one that isn't connected. fn forget_oldest(&mut self) { if self.issued.len() > MAX_REMEMBERED @@ -522,6 +535,7 @@ impl Server { } } self.deleted.extend(applied.deleted); + self.forget_deleted(); for (entry, now) in entries { if now { self.removed.remove(&entry); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9dc85e6e..6ec7e824 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -826,12 +826,38 @@ impl Node { self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0 } - /// Maximum distance (in local units) painting can reach outside the node box. + /// Maximum distance painting can reach outside the node box: strokes and glyphs (local units) + /// and effects (page units, see [`Node::effect_paint_reach`]), the same when the node's + /// transform doesn't scale. pub fn paint_reach(&self) -> f64 { - self.outline_reach() + self.effects_reach() + self.spread_miter_reach() + self.outline_reach() + self.effect_paint_reach() } - /// How far (local units) this node's effects paint past what they apply to: the layer effects + /// How far (page units: effects are drawn at the same size however a layer is scaled) this + /// node's effects paint outside its box, drop shadows' spread at sharp corners included. + pub fn effect_paint_reach(&self) -> f64 { + self.effects_reach() + effect_reach(self.spread_miter_reach()) + } + + /// Each visible drop shadow with a spread, as (local units, page units): a filled outline is + /// grown by the spread (sharp corners' mitre included) before it is transformed, so that part + /// follows the node's scale, and the shadow then reaches as far again as its offset and blur, + /// from the layer its layer effects moved. Layers that aren't one outline spread in page units + /// instead, as counted in [`Node::effect_paint_reach`]. + pub fn outline_spreads(&self) -> impl Iterator + '_ { + let corners = if self.grows_by_exactly_its_spread() { 1.0 } else { SPREAD_MITER_LIMIT }; + let layer = self.layer_effects_reach(); + let outlined = self.may_spread_as_an_outline(); + self.effects.iter().filter(|e| e.visible).filter_map(move |e| match &e.kind { + EffectKind::DropShadow(s) if outlined && s.spread.is_finite() && s.spread > 0.0 => { + let page = s.radius.abs() * 1.5 + s.offset.0.abs().max(s.offset.1.abs()); + Some((effect_reach(s.spread) * corners, layer + effect_reach(page))) + } + _ => None, + }) + } + + /// How far (page units) this node's effects paint past what they apply to: the layer effects /// together (see [`Node::layer_effects_reach`]), then the largest of the others. pub fn effects_reach(&self) -> f64 { let fx = self.effects.iter().filter(|e| !e.moves_the_layer()).map(|e| effect_reach(e.reach())).fold(0.0, f64::max); @@ -853,7 +879,30 @@ impl Node { /// grown by its strokes and glyphs, then by its effects (see [`Node::grow_by_effects`]). pub fn own_paint_bounds(&self, world: Affine) -> figcraft_geom::Rect { let o = self.outline_reach(); - self.grow_by_effects(figcraft_geom::transform_rect_bbox(world, self.local_rect().inflate(o, o))) + let base = self.local_rect().inflate(o, o); + let grown = self.grow_by_effects(figcraft_geom::transform_rect_bbox(world, base)); + // A filled outline's spread grows in local units (see [`Node::outline_spreads`]): a box by + // its spread all round, sharp corners' mitres anywhere within a circle of it. + let [a, b, c, d, _, _] = world.as_coeffs(); + let (exact, layer, outlined) = (self.grows_by_exactly_its_spread(), self.layer_effects_reach(), self.may_spread_as_an_outline()); + self.effects.iter().filter(|e| e.visible).fold(grown, |r, e| match &e.kind { + EffectKind::DropShadow(s) if outlined && s.spread.is_finite() && s.spread > 0.0 => { + let spread = effect_reach(s.spread); + let outline = if exact { + figcraft_geom::transform_rect_bbox(world, base.inflate(spread, spread)) + } else { + let m = spread * SPREAD_MITER_LIMIT; + figcraft_geom::transform_rect_bbox(world, base).inflate(m * a.hypot(c), m * b.hypot(d)) + }; + let (g, (dx, dy)) = (layer + s.radius.abs() * 1.5, s.offset); + if g.is_finite() && dx.is_finite() && dy.is_finite() && outline.is_finite() { + r.union(outline.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) + } else { + r + } + } + _ => r, + }) } /// Page-space area `b` grown by what this node's visible effects paint around it: blurs all @@ -880,7 +929,7 @@ impl Node { } /// How far (local units) strokes and glyphs reach outside the box. - fn outline_reach(&self) -> f64 { + pub fn outline_reach(&self) -> f64 { let stroke = if self.has_visible_stroke() { let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or_else(|| self.stroke.max_weight()); let base = match self.stroke.align { @@ -904,11 +953,23 @@ impl Node { stroke + glyphs } + /// Whether a drop shadow's spread can grow this node's outline (see [`Node::outline_spreads`]): + /// not a group's or stroked text's, which the renderer spreads as pixels, in page units. Others + /// can still turn out to be drawn that way (children, images with alpha): counted both ways. + fn may_spread_as_an_outline(&self) -> bool { + !(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke())) + } + + /// Whether a spread grows this node's outline by exactly the spread (no sharp corners). + fn grows_by_exactly_its_spread(&self) -> bool { + matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() + } + /// How much further than its spread a drop shadow can reach at the outline's sharp corners: /// spread moves the edges out in parallel and they meet in a mitre (see /// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread. fn spread_miter_reach(&self) -> f64 { - if matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() { + if self.grows_by_exactly_its_spread() { return 0.0; } self.effects diff --git a/crates/engine/clippy.toml b/crates/engine/clippy.toml index 3ceac899..4cf321bd 100644 --- a/crates/engine/clippy.toml +++ b/crates/engine/clippy.toml @@ -6,8 +6,8 @@ allow-expect-in-tests = true allow-panic-in-tests = true too-many-arguments-threshold = 10 disallowed-methods = [ - { path = "std::fs::read", reason = "use file_access::read (automation roots)" }, - { path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" }, + { path = "std::fs::read", reason = "use file_access::read_capped (automation roots, size caps)" }, + { path = "std::fs::read_to_string", reason = "use file_access::read_capped (automation roots, size caps)" }, { path = "std::fs::write", reason = "use file_access::write (automation roots)" }, { path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" }, { path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" }, @@ -35,5 +35,5 @@ disallowed-methods = [ { path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" }, { path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" }, { path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" }, - { path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" }, + { path = "figcraft_format::load_file", reason = "use file_access::read_capped (automation roots, size caps)" }, ] diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index b9dbce47..5950b681 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -26,6 +26,15 @@ pub const NOT_FETCHED: &str = "the library hasn't been fetched from its server y pub const NO_LIBRARY: &str = "that file has no library on the server yet: publish it there first (Libraries ▸ Publish to server)"; /// Most components one usage report lists. pub const MAX_USAGE: usize = 10_000; +/// Most team libraries remembered as yours ([`Prefs::team_libraries`](crate::Prefs)): adding or +/// checking another forgets the one used longest ago. +pub const MAX_TEAM_LIBRARIES: usize = 500; + +/// How [`Prefs::team_libraries`](crate::Prefs) keeps a link: its SHA-256, so the preferences hold +/// no token. +fn link_key(link: &str) -> String { + figcraft_format::image_hash(link.trim().as_bytes()) +} /// Whether a library's path is a share link of a file on a server (else it is a library file). pub fn is_server_link(path: &str) -> bool { @@ -156,15 +165,35 @@ impl Session { self.take_library(path, reply).map_err(EngineError::Other) } + /// Whether team library `link` is one you added or checked here ([`Prefs::team_libraries`](crate::Prefs)): + /// only those are checked for updates by themselves. + pub fn library_allowed(&self, link: &str) -> bool { + self.prefs.team_libraries.contains(&link_key(link)) + } + + /// Remember team library `link` as one you use (the app saves the preferences at once). + pub(crate) fn allow_library(&mut self, link: &str) { + let key = link_key(link); + let list = &mut self.prefs.team_libraries; + if list.first() != Some(&key) { + list.retain(|k| *k != key); + list.insert(0, key); + list.truncate(MAX_TEAM_LIBRARIES); + self.live.unsaved = true; + } + } + /// Ask the servers of the active document's team libraries for newer versions, in the /// background, reporting how the document uses each (the Assets panel calls it now and then). + /// Only libraries you added or checked here are asked: opening a file that names another + /// server contacts nothing until you check it (`library.check`, #1097). pub fn refresh_server_libraries(&mut self) { let Ok(doc) = self.doc() else { return }; let jobs: Vec<(String, LibraryReq)> = doc .libraries .enabled .iter() - .filter(|l| is_server_link(&l.path)) + .filter(|l| is_server_link(&l.path) && self.library_allowed(&l.path)) .map(|l| { let have = match self.libraries.cached(&l.path) { Some(Ok(lib)) => Some(lib.manifest.version), diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs index f741e21c..aeaa3c13 100644 --- a/crates/engine/src/automation/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -236,21 +236,42 @@ fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ { move |e| EngineError::Other(format!("{}: {e}", path.display())) } -/// The bytes of file `path`. -pub fn read(path: impl AsRef) -> Result> { - let path = path.as_ref(); - std::fs::read(allow(path, Access::Read)?).map_err(io(path)) +/// `bytes` for a person: "300 bytes", "12 MB", "6.5 GB". +fn size(bytes: u64) -> String { + match bytes { + b if b < 1 << 20 => format!("{b} bytes"), + b if b < 1 << 30 => format!("{} MB", b.div_ceil(1 << 20)), + b => format!("{:.1} GB", b as f64 / f64::from(1u32 << 30)), + } } -/// The bytes of file `path`, refused when it isn't a file or is larger than `max`. +/// The bytes of file `path`, at most `max` of them. A folder, a device, a pipe or a file larger +/// than `max` is refused before anything is read, so a path a caller gives can't fill memory or +/// wait forever (#1114). Every whole-file read of a path goes through here. pub fn read_capped(path: impl AsRef, max: u64) -> Result> { + use std::io::Read as _; let path = path.as_ref(); let p = allow(path, Access::Read)?; + let refuse = |why: String| EngineError::Other(format!("{}: {why}", path.display())); + // Looked at before opening: opening a pipe waits for whoever writes it. let meta = std::fs::metadata(&p).map_err(io(path))?; - if !meta.is_file() || meta.len() > max { - return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display()))); + if meta.is_dir() { + return Err(refuse("is a folder, not a file".into())); } - std::fs::read(p).map_err(io(path)) + if !meta.is_file() { + return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into())); + } + if meta.len() > max { + return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max)))); + } + let f = std::fs::File::open(p).map_err(io(path))?; + let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); + f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?; + // It grew after it was looked at. + if bytes.len() as u64 > max { + return Err(refuse(format!("grew past the {} allowed here while it was read", size(max)))); + } + Ok(bytes) } /// File `path` opened for reading. diff --git a/crates/engine/src/automation/file_access/tests.rs b/crates/engine/src/automation/file_access/tests.rs index ba55e134..72f471f9 100644 --- a/crates/engine/src/automation/file_access/tests.rs +++ b/crates/engine/src/automation/file_access/tests.rs @@ -198,10 +198,10 @@ fn a_scope_confines_the_thread_and_ends_with_it() { assert!(current().is_none()); confined(Some(&roots), || { assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err()); - assert!(super::read(f.p("outside/secret.txt")).is_err()); - assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in"); + assert!(super::read_capped(f.p("outside/secret.txt"), 64).is_err()); + assert_eq!(super::read_capped(f.p("root/in.txt"), 64).unwrap(), b"in"); // Not confined: the app's own files. - assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret"); + assert_eq!(super::app_owned(|| super::read_capped(f.p("outside/secret.txt"), 64).unwrap()), b"secret"); assert!(current().is_some(), "back in the scope after app_owned"); // Another thread isn't confined by this one's scope. let p = f.p("outside/secret.txt"); @@ -386,3 +386,8 @@ fn only_resolved_paths_count_as_the_apps_own_folder() { assert!(!super::within_app_folder(&install.join("missing.wasm"), &install)); assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder"))); } + +#[test] +fn sizes_read_as_a_person_says_them() { + assert_eq!((super::size(300), super::size((12 << 20) - 5), super::size(13 << 29)), ("300 bytes".into(), "12 MB".into(), "6.5 GB".into())); +} diff --git a/crates/engine/src/batch_undo_snapshot_tests.rs b/crates/engine/src/batch_undo_snapshot_tests.rs new file mode 100644 index 00000000..81f415c7 --- /dev/null +++ b/crates/engine/src/batch_undo_snapshot_tests.rs @@ -0,0 +1,108 @@ +//! A batch keeps one undo snapshot, not one per step (#1099): it is still one undo step that +//! restores exactly what was there before, and a failure still rolls all of it back. + +use serde_json::{Value, json}; + +use crate::{NodeId, Session}; + +fn id(value: &Value) -> NodeId { + crate::params::id_of(&value["id"]).unwrap() +} + +fn rectangles(s: &mut Session, n: usize) -> Vec { + (0..n).map(|i| id(&s.execute("shape.rectangle", &json!({"x": i * 20, "y": 0, "width": 10, "height": 10})).unwrap())).collect() +} + +fn rename(target: NodeId, name: &str) -> Value { + json!({"command": "node.set", "params": {"id": target.0, "props": {"name": name}}}) +} + +fn undo_len(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +#[test] +fn a_thousand_step_batch_keeps_one_snapshot_and_undoes_as_one_step() { + let mut s = Session::new(); + let ids = rectangles(&mut s, 50); + let (before, base) = (s.doc().unwrap().clone(), undo_len(&s)); + let mut most = 0; + s.grouped("Many", |s| { + for i in 0..1000 { + let target = ids[i % ids.len()]; + s.execute("node.set", &json!({"id": target.0, "props": {"name": format!("Step {i}")}}))?; + most = most.max(undo_len(s)); + } + Ok(()) + }) + .unwrap(); + assert_eq!(most, base + 1, "one snapshot held for the whole group, not one per step"); + assert_eq!(undo_len(&s), base + 1); + assert_eq!(s.undo_labels(1), vec!["Many".to_string()]); + let after = s.doc().unwrap().clone(); + assert!(s.undo().unwrap()); + assert_eq!(s.doc().unwrap(), &before, "undo puts back exactly what was there"); + assert!(s.redo().unwrap()); + assert_eq!(s.doc().unwrap(), &after); +} + +#[test] +fn command_batch_and_its_continue_mode_are_one_step_each() { + for mode in ["abort", "continue"] { + let mut s = Session::new(); + let ids = rectangles(&mut s, 10); + let (before, base) = (s.doc().unwrap().clone(), undo_len(&s)); + let mut steps: Vec = (0..1000).map(|i| rename(ids[i % ids.len()], &format!("{mode} {i}"))).collect(); + if mode == "continue" { + steps.insert(500, json!({"command": "node.set", "params": {"id": 999_999, "props": {"name": "missing"}}})); + } + s.execute("command.batch", &json!({"onError": mode, "commands": steps})).unwrap(); + assert_eq!(undo_len(&s), base + 1, "{mode}"); + assert_eq!(s.doc().unwrap().node(ids[9]).unwrap().name, format!("{mode} 999")); + assert!(s.undo().unwrap()); + assert_eq!(s.doc().unwrap(), &before, "{mode}"); + } +} + +#[test] +fn a_failing_step_after_hundreds_rolls_back_everything() { + let mut s = Session::new(); + let ids = rectangles(&mut s, 10); + let (before, labels) = (s.doc().unwrap().clone(), s.undo_labels(100)); + let mut steps: Vec = (0..500).map(|i| rename(ids[i % ids.len()], &format!("R {i}"))).collect(); + steps.push(json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 5, "height": 5}})); + steps.push(json!({"command": "no.such.command"})); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_labels(100), labels); + assert_eq!(s.undo_depth().1, 0); +} + +#[test] +fn undo_inside_a_batch_undoes_the_batch_so_far() { + let make = |x: i32| json!({"command": "shape.rectangle", "params": {"x": x, "y": 40, "width": 10, "height": 10}}); + let start = || { + let mut s = Session::new(); + let first = rectangles(&mut s, 1)[0]; + (s, first) + }; + // An edit and its undo: nothing changes. + let (mut s, _) = start(); + let before = s.doc().unwrap().clone(); + s.execute("command.batch", &json!({"commands": [make(0), {"command": "edit.undo"}]})).unwrap(); + assert_eq!(s.doc().unwrap(), &before); + // The batch's edits so far go back together; the next undo is the step before the batch. + let (mut s, first) = start(); + s.execute("command.batch", &json!({"commands": [make(0), make(20), {"command": "edit.undo"}, {"command": "edit.undo"}]})).unwrap(); + assert!(!s.doc().unwrap().contains(first)); + assert!(s.undo().unwrap()); + assert!(s.doc().unwrap().contains(first)); + // Undo then redo inside: the edits come back and stay one step. + let (mut s, _) = start(); + let r = s.execute("command.batch", &json!({"commands": [make(0), {"command": "edit.undo"}, {"command": "edit.redo"}, make(20)]})).unwrap(); + assert_eq!(r[2]["redone"], true); + let count = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + assert_eq!(count(&s), 3); + assert!(s.undo().unwrap()); + assert_eq!(count(&s), 1); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 83f7a13e..1e1f8f3b 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -410,9 +410,10 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { }) } +/// An image file to place: no larger than a document takes an image from outside (#1114). #[cfg(not(target_arch = "wasm32"))] fn read_path(path: &str) -> Result> { - crate::file_access::read(path) + crate::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1c9402e9..4939bc2c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,9 +30,9 @@ pub use export::{ }; use export::{export, export_frames_pdf, export_layers, pdf_bytes}; use load::font_report; +pub use load::{MAX_DOCUMENT_FILE, load, load_bytes, load_path}; #[cfg(test)] pub(crate) use load::{installed_family, sketch_line}; -pub use load::{load, load_bytes, load_path}; pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] @@ -326,16 +326,24 @@ pub fn specs() -> Vec { spec!( "doc.list", "List documents", - "{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`", + "{} → [{index, id, name, path?, dirty, active}]: `id` names the file in a control or MCP call's `document`; `path` only for the user's own scripts, not AI agents or plugins", false, |s, _| { + // Where the files are on disk is the user's: an agent gets their names (#1117). + let paths = s.agent.is_none(); Ok(Value::Array( - s.docs - .iter() - .enumerate() - .map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})) - .collect(), - )) + s.docs + .iter() + .enumerate() + .map(|(i, d)| { + let mut e = json!({"index": i, "id": d.uid, "name": d.doc.name, "dirty": d.is_dirty(), "active": i == s.active}); + if paths { + e["path"] = json!(d.path); + } + e + }) + .collect(), + )) } ), spec!("doc.home", "Home", "{}", false, |s, _| { diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 8234ad09..1ed6dafe 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -14,9 +14,14 @@ pub(crate) fn recovered_from(bytes: &[u8]) -> Result { Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) } +/// The largest file opened or imported as a document. A `.figcraft` file keeps its images as +/// they are, so a real one can be far larger than it unpacks to (#1313's took 7.4 GB); twice that +/// opens, while a device, a pipe or an absurd file is refused before it is read (#1114). +pub const MAX_DOCUMENT_FILE: u64 = 16 << 30; + #[cfg(not(target_arch = "wasm32"))] pub(super) fn read(path: &str) -> Result> { - crate::file_access::read(path) + crate::file_access::read_capped(path, MAX_DOCUMENT_FILE) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 0c7edbb2..f4f70ce4 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -85,6 +85,13 @@ pub fn specs() -> Vec { false, updates ), + spec!( + "library.check", + "Check team libraries for updates", + "{library? (an enabled team library: id, name or link; all of this file's when omitted)} asks their servers for the latest version in the background, and from then on checks them by themselves: opening a file doesn't contact the server of a team library you haven't added or checked → {checking}", + false, + check + ), spec!( "library.acceptUpdates", "Accept library updates", @@ -524,6 +531,10 @@ fn add(s: &mut Session, p: &Value) -> Result { let raw = req_string(cmd, p, "path")?; let path = if is_server_link(&raw) { raw.trim().to_string() } else { absolute_path(raw.trim()) }; let lib = s.load_library(&path)?; + if is_server_link(&path) { + // Added by you: its server may be checked for updates from now on (#1097). + s.allow_library(&path); + } let m = &lib.manifest; let doc = s.doc()?; if doc.published.as_ref().is_some_and(|me| me.manifest.id == m.id) { @@ -549,6 +560,27 @@ fn add(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// `library.check`: the user lets this file's team libraries (or one) be checked; they are asked now. +fn check(s: &mut Session, p: &Value) -> Result { + let cmd = "library.check"; + let doc = s.doc()?; + let links: Vec = match string(p, "library") { + Some(key) => { + let l = find_link(doc, &key).ok_or_else(|| bad(cmd, format!("no enabled library `{key}` (library.list shows them)")))?; + if !is_server_link(&l.path) { + return Err(bad(cmd, format!("`{key}` is a library file, not a team library on a server"))); + } + vec![l.path.clone()] + } + None => doc.libraries.enabled.iter().filter(|l| is_server_link(&l.path)).map(|l| l.path.clone()).collect(), + }; + for l in &links { + s.allow_library(l); + } + s.refresh_server_libraries(); + Ok(json!({"checking": links.len()})) +} + fn remove(s: &mut Session, p: &Value) -> Result { let cmd = "library.remove"; let key = req_string(cmd, p, "id")?; diff --git a/crates/engine/src/doc_list_paths_tests.rs b/crates/engine/src/doc_list_paths_tests.rs new file mode 100644 index 00000000..0c62a18d --- /dev/null +++ b/crates/engine/src/doc_list_paths_tests.rs @@ -0,0 +1,48 @@ +//! `doc.list` tells the user's own scripts where the open files are, but AI agents and plugins +//! get their names only (#1117). + +use serde_json::json; + +use crate::Session; +use crate::components::tests::ex; +use crate::plugins_tests::{OnRefusal, install, manifest, script}; + +const SECRET: &str = "/home/someone/private/clients/acme.figcraft"; + +fn two_files() -> Session { + let mut s = Session::new(); + s.docs[0].path = Some(SECRET.into()); + ex(&mut s, "file.new", json!({"name": "Front"})); + s +} + +#[test] +fn the_users_own_calls_get_the_paths() { + let mut s = two_files(); + let list = ex(&mut s, "doc.list", json!({})); + assert_eq!(list[0]["path"], json!(SECRET)); + assert_eq!(list[1]["path"], json!(null), "a new file has none yet"); +} + +#[test] +fn an_agent_gets_names_and_ids_but_no_paths() { + let mut s = two_files(); + let list = s.execute_as(Some("Claude"), "doc.list", &json!({})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + assert!(list[0]["id"].is_u64() && list[0]["name"].is_string()); + assert!(list.as_array().unwrap().iter().all(|e| e.get("path").is_none()), "{list}"); + // An agent with no name of its own is an agent too. + let list = s.execute_as(Some(""), "doc.list", &json!({})).unwrap(); + assert!(!list.to_string().contains(SECRET)); +} + +#[test] +fn a_plugin_gets_names_but_no_paths() { + let mut s = two_files(); + // The reply to its last call becomes its output. + let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))"; + install(&mut s, manifest("org.test.list", "read", &[]), &script(&[("doc.list", json!({}))], OnRefusal::Fail, tail)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.list"}))["output"].to_string(); + assert!(out.contains("Front"), "{out}"); + assert!(!out.contains(SECRET) && !out.contains("path"), "{out}"); +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index a9cad9c9..ba412ba6 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -15,6 +15,9 @@ pub struct History { pub undo: Vec, pub redo: Vec, pub limit: usize, + /// Inside a group, the undo depth just after the step the group pushed: the group's later + /// edits add to that step instead of keeping a snapshot each (#1099). + pub(crate) group_step: Option, } /// An in-progress gesture: the document before it started, so previews re-apply from scratch. diff --git a/crates/engine/src/extensions/plugins.rs b/crates/engine/src/extensions/plugins.rs index 4dbfa7e6..dbc08336 100644 --- a/crates/engine/src/extensions/plugins.rs +++ b/crates/engine/src/extensions/plugins.rs @@ -107,6 +107,8 @@ const DENIED: &[&str] = &[ "plugins.", "libraries.", "library.add", + // Contacts the servers a file names, and remembers them as yours (#1097). + "library.check", // Uploads the open document to a server. "library.publishToServer", "edit.undo", @@ -232,7 +234,16 @@ impl figcraft_plugin::Host for EngineHost<'_> { } Ok(v) }); - r.map(|v| v.to_string()).map_err(|e| e.to_string()) + r.map(|mut v| { + // A plugin gets the open files' names, not where they are on disk (#1117). + if command == "doc.list" { + for e in v.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) { + e.remove("path"); + } + } + v.to_string() + }) + .map_err(|e| e.to_string()) } fn selection(&mut self) -> String { @@ -313,11 +324,7 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I /// Read a file of at most `max` bytes. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result> { - let meta = crate::file_access::metadata(path)?; - if !meta.is_file() || meta.len() > max as u64 { - return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024))); - } - crate::file_access::read(path) + crate::file_access::read_capped(path, max as u64) } /// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs index b6897b2a..4acc8621 100644 --- a/crates/engine/src/import_export_tests.rs +++ b/crates/engine/src/import_export_tests.rs @@ -128,7 +128,10 @@ fn an_explicit_variables_path_is_capped_like_the_sibling_file() { for cmd in ["file.open", "file.import"] { let before = state(&s); let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string(); - assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}"); + assert!( + ["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), + "{cmd} {bad}: {e}" + ); assert_eq!(state(&s), before, "{cmd} {bad}"); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf40c15f..cedc61be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -197,6 +197,8 @@ mod batch_rollback_tests; #[cfg(test)] mod batch_step_ref_tests; #[cfg(test)] +mod batch_undo_snapshot_tests; +#[cfg(test)] mod binding_lists_tests; #[cfg(test)] mod board_connector_tests; @@ -275,6 +277,8 @@ mod devmode_behaviour_tests; #[cfg(test)] mod devmode_tail_tests; #[cfg(test)] +mod doc_list_paths_tests; +#[cfg(test)] mod doc_urls_tests; #[cfg(test)] mod double_click_tests; @@ -357,6 +361,8 @@ mod jam_open_tests; #[cfg(test)] mod keymap_tests; #[cfg(test)] +mod library_beacon_tests; +#[cfg(test)] mod library_path_tests; #[cfg(test)] mod library_range_styles_tests; @@ -369,6 +375,8 @@ mod live_access_tests; #[cfg(test)] mod live_docs_tests; #[cfg(test)] +mod live_peer_cap_tests; +#[cfg(test)] mod live_share_tests; #[cfg(test)] mod live_signal_tests; @@ -411,6 +419,8 @@ mod paint_tests; #[cfg(test)] mod panels_behaviour_tests; #[cfg(test)] +mod path_read_caps_tests; +#[cfg(test)] mod pdf_print_tests; #[cfg(test)] mod pen_end_tests; diff --git a/crates/engine/src/library_beacon_tests.rs b/crates/engine/src/library_beacon_tests.rs new file mode 100644 index 00000000..187ea526 --- /dev/null +++ b/crates/engine/src/library_beacon_tests.rs @@ -0,0 +1,117 @@ +//! Opening a file that names a team library on some server doesn't contact that server (#1097): +//! only team libraries you added or checked are asked for updates by themselves. + +use std::sync::{Arc, Mutex}; + +use figcraft_collab::message::{ClientMsg, LibraryReply, LibraryReq}; +use figcraft_doc::{Document, LibraryLink}; +use serde_json::json; + +use crate::live::{Event, HostOptions, LiveLink, NetRequest, Presence, SessionInfo}; +use crate::{Prefs, Session}; + +const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret"; + +/// Every request the app would send anywhere. +#[derive(Default)] +struct Wire { + sent: Vec, +} + +struct Link(Arc>); + +impl Link { + fn note(&self, what: &str) { + self.0.lock().unwrap().sent.push(what.into()); + } +} + +impl LiveLink for Link { + fn host(&mut self, _: u64, _: HostOptions) -> Result { + self.note("host"); + Err("no".into()) + } + fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { + self.note("join"); + Err("no".into()) + } + fn leave(&mut self, _: u64) {} + fn presence(&mut self, _: u64, _: &Presence) {} + fn send(&mut self, _: u64, _: ClientMsg) { + self.note("send"); + } + fn poll(&mut self) -> Vec<(u64, Event)> { + Vec::new() + } + fn library(&mut self, link: &str, _: LibraryReq) -> Result { + self.note(link); + Ok(LibraryReply::Empty) + } + fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) { + for (link, _) in jobs { + self.note(&link); + } + } + fn background(&mut self, _: u64, _: NetRequest) -> bool { + self.note("background"); + false + } +} + +/// A session with `prefs` that opened a file (from disk) whose libraries include [`LINK`]. +fn opened(prefs: Prefs, tag: &str) -> (Session, Arc>, std::path::PathBuf) { + let mut d = Document::new("Someone else's file"); + d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3 }); + let path = std::env::temp_dir().join(format!("figcraft-beacon-{}-{tag}.figcraft", std::process::id())); + std::fs::write(&path, figcraft_format::save_bytes(&d, None).unwrap()).unwrap(); + let mut s = Session::new(); + s.prefs = prefs; + let wire = Arc::new(Mutex::new(Wire::default())); + s.live.install(Box::new(Link(wire.clone()))); + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.doc().unwrap().libraries.enabled.len(), 1); + (s, wire, path) +} + +fn sent(w: &Arc>) -> Vec { + w.lock().unwrap().sent.clone() +} + +#[test] +fn opening_a_file_naming_an_unknown_team_library_contacts_nothing() { + let (mut s, wire, path) = opened(Prefs::default(), "unknown"); + // What the Assets panel does now and then while the file is shown. + for _ in 0..3 { + s.refresh_server_libraries(); + } + assert!(sent(&wire).is_empty(), "no connection attempt: {:?}", sent(&wire)); + assert!(!s.library_allowed(LINK)); + // You check it: now it is asked, and from then on by itself. + assert_eq!(s.execute("library.check", &json!({})).unwrap()["checking"], 1); + assert_eq!(sent(&wire), vec![LINK.to_string()]); + assert!(s.library_allowed(LINK) && s.live.unsaved, "kept with the preferences"); + s.refresh_server_libraries(); + assert_eq!(sent(&wire).len(), 2); + // Kept as a hash: the preferences hold no token. + assert!(!serde_json::to_string(&s.prefs).unwrap().contains("per-file-secret")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn a_team_library_you_checked_before_is_checked_when_a_file_names_it() { + let mut mine = Session::new(); + mine.allow_library(LINK); + let (mut s, wire, path) = opened(mine.prefs.clone(), "checked"); + s.refresh_server_libraries(); + assert_eq!(sent(&wire), vec![LINK.to_string()]); + let _ = std::fs::remove_file(path); +} + +#[test] +fn checking_one_team_library_by_name() { + let (mut s, wire, path) = opened(Prefs::default(), "named"); + assert!(s.execute("library.check", &json!({"library": "Nope"})).is_err()); + assert_eq!(s.execute("library.check", &json!({"library": "Kit"})).unwrap()["checking"], 1); + assert_eq!(sent(&wire), vec![LINK.to_string()]); + let _ = std::fs::remove_file(path); +} diff --git a/crates/engine/src/live_peer_cap_tests.rs b/crates/engine/src/live_peer_cap_tests.rs new file mode 100644 index 00000000..2035aa8c --- /dev/null +++ b/crates/engine/src/live_peer_cap_tests.rs @@ -0,0 +1,36 @@ +//! A hostile server can't grow a client's list of people without bound (#1121): past +//! [`MAX_PEERS`], someone new is left out (their presence and chat with them), in a welcome or one +//! join at a time; someone leaving makes room again. + +use serde_json::json; + +use crate::live::{Event, MAX_PEERS, Peer, Presence, Role}; +use crate::live_tests::{info, peer, push, session}; + +fn many(from: u32, n: usize) -> Vec { + (from..).take(n).map(|id| peer(id, "Someone")).collect() +} + +#[test] +fn a_flood_of_joins_keeps_at_most_the_cap() { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 1), peers: many(2, MAX_PEERS + 100) }]); + s.live_poll(); + assert_eq!(s.live().peers.len(), MAX_PEERS, "a welcome listing too many"); + let first_left_out = 2 + MAX_PEERS as u32; + let joins = many(first_left_out, 1_000).into_iter().map(Event::PeerJoined).collect(); + push(&script, joins); + s.live_poll(); + assert_eq!(s.live().peers.len(), MAX_PEERS, "joins past the cap"); + // Presence of someone left out isn't kept either. + push(&script, vec![Event::Presence(first_left_out, Presence::default())]); + s.live_poll(); + assert!(!s.live().presence.contains_key(&first_left_out)); + // Someone already here may still be renamed; someone leaving makes room. + push(&script, vec![Event::PeerJoined(peer(2, "Renamed")), Event::PeerLeft(3), Event::PeerJoined(peer(first_left_out, "Late"))]); + s.live_poll(); + assert_eq!(s.live().peers.len(), MAX_PEERS); + assert_eq!(s.live().peers[&2].name, "Renamed"); + assert!(s.live().peers.contains_key(&first_left_out) && !s.live().peers.contains_key(&3)); +} diff --git a/crates/engine/src/path_read_caps_tests.rs b/crates/engine/src/path_read_caps_tests.rs new file mode 100644 index 00000000..2805bf51 --- /dev/null +++ b/crates/engine/src/path_read_caps_tests.rs @@ -0,0 +1,96 @@ +//! Paths callers give are read only when they are regular files within a cap that fits their +//! kind (#1114): a folder, a device or an oversized file is refused with a clear error before +//! anything is read, and the session is left as it was. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::Session; +use crate::file_access::read_capped; + +struct Dir(PathBuf); + +impl Dir { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("figcraft-caps-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("folder")).unwrap(); + Self(d) + } + fn p(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().into_owned() + } + /// A sparse file of `len` bytes (nothing is written). + fn sized(&self, rel: &str, len: u64) -> String { + std::fs::File::create(self.0.join(rel)).unwrap().set_len(len).unwrap(); + self.p(rel) + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// `cmd` with `params` fails with a message containing `why`, and changes no document. +fn refused(s: &mut Session, cmd: &str, params: Value, why: &str) { + let before = (format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::>()), s.active); + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{cmd} {params}: {e}"); + assert_eq!((format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::>()), s.active), before, "{cmd} {params}"); +} + +const FOLDER: &str = "is a folder, not a file"; +const DEVICE: &str = "isn't a regular file"; + +#[test] +fn the_capped_read_refuses_folders_devices_and_oversized_files() { + let d = Dir::new("read"); + std::fs::write(d.0.join("four.txt"), b"four").unwrap(); + assert_eq!(read_capped(d.p("four.txt"), 4).unwrap(), b"four"); + let e = read_capped(d.p("four.txt"), 3).unwrap_err().to_string(); + assert!(e.contains("is 4 bytes, more than the 3 bytes allowed here"), "{e}"); + assert!(read_capped(d.p("folder"), u64::MAX).unwrap_err().to_string().contains(FOLDER)); + if cfg!(unix) { + // An endless source: refused, not read until memory runs out. + assert!(read_capped("/dev/zero", u64::MAX).unwrap_err().to_string().contains(DEVICE)); + } +} + +#[test] +fn documents_are_refused_as_folders_and_devices() { + let d = Dir::new("docs"); + let mut s = Session::new(); + for cmd in ["file.open", "file.import"] { + refused(&mut s, cmd, json!({"path": d.p("folder")}), FOLDER); + if cfg!(unix) { + refused(&mut s, cmd, json!({"path": "/dev/zero"}), DEVICE); + } + } + const { assert!(crate::cmd::file::MAX_DOCUMENT_FILE > 7_400_000_000, "a real 7.4 GB file still opens") }; +} + +#[test] +fn placed_images_are_capped_at_what_a_document_takes() { + let d = Dir::new("images"); + let mut s = Session::new(); + let big = d.sized("huge.png", figcraft_doc::ImageData::MAX_BYTES + 1); + refused(&mut s, "image.place", json!({"path": big}), "more than the 256 MB allowed here"); + refused(&mut s, "image.place", json!({"path": d.p("folder")}), FOLDER); + if cfg!(unix) { + refused(&mut s, "image.place", json!({"path": "/dev/zero"}), DEVICE); + } +} + +#[test] +fn fonts_and_plugins_are_refused_as_folders() { + let d = Dir::new("plugins"); + let mut s = Session::new(); + refused(&mut s, "text.loadFont", json!({"path": d.p("folder")}), FOLDER); + // A plugin folder whose manifest is a folder. + std::fs::create_dir_all(d.0.join("plugin").join("figcraft-plugin.json")).unwrap(); + refused(&mut s, "plugin.import", json!({"path": d.p("plugin")}), FOLDER); + assert!(s.prefs.plugins.is_empty()); +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3c241ef6..70948221 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -110,6 +110,12 @@ pub struct Prefs { /// `widget:`. #[serde(skip_serializing_if = "Vec::is_empty")] pub recent_actions: Vec, + /// The team libraries you added (`library.add`) or checked (`library.check`), as hashes of + /// their links (which carry tokens), newest first, at most + /// [`MAX_TEAM_LIBRARIES`](crate::library_server::MAX_TEAM_LIBRARIES): only these are checked + /// for updates by themselves. A file naming another one doesn't contact its server (#1097). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub team_libraries: Vec, } /// Recently run actions kept for the Actions palette (newest first). @@ -150,6 +156,7 @@ impl Default for Prefs { view: BTreeMap::new(), board_grid: Default::default(), recent_actions: Vec::new(), + team_libraries: Vec::new(), } } } diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs index 6b0b265d..05024481 100644 --- a/crates/engine/src/session/exec.rs +++ b/crates/engine/src/session/exec.rs @@ -256,8 +256,18 @@ impl Session { if self.live_doc(uid) { return self.live_edit(uid, label); } + let grouped = self.group_depth > 0; if let (Some(label), Some(ds)) = (label, self.docs.iter_mut().find(|d| d.uid == uid)) { + // A group becomes one step at its end: its edits add to the step it already pushed, + // so a long batch keeps one snapshot, not one per edit (#1099). + if grouped && ds.history.group_step == Some(ds.history.undo.len()) { + ds.history.redo.clear(); + return; + } ds.push_undo(label, before, selection, page); + if grouped { + ds.history.group_step = Some(ds.history.undo.len()); + } } } } diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index 285fc8a6..a10b3f4b 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -13,6 +13,7 @@ struct GroupDocCheckpoint { undo: Vec, redo: Vec, limit: usize, + group_step: Option, } struct GroupCheckpoint { @@ -259,7 +260,7 @@ impl Session { /// Without a label, only roll back failures: leave successful steps' history to the outer group. /// A checkpoint also works between closing the last tab and opening another one. fn checkpointed(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result) -> Result { - let before = self.group_checkpoint(); + let before = self.group_checkpoint(label.is_some()); let result = f(self); self.group_depth -= 1; let at_root = self.group_depth == 0; @@ -275,6 +276,7 @@ impl Session { let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) else { continue }; ds.history.limit = saved.limit; if label.is_some() { + ds.history.group_step = saved.group_step; if !Arc::ptr_eq(&saved.doc, &ds.doc) && ds.interaction.is_none() { changed.push((saved.uid, saved.doc.clone(), saved.selection.clone(), saved.page)); } @@ -288,13 +290,19 @@ impl Session { } } if at_root { + // Tabs opened inside the group have no checkpoint to restore theirs from. + for ds in &mut self.docs { + ds.history.group_step = None; + } self.closed_in_group.clear(); self.flush_pending_events(); } result } - fn group_checkpoint(&mut self) -> GroupCheckpoint { + /// `own_step`: the group becomes an undo step of its own (it has a label), so its edits don't + /// add to the step of a group around it. + fn group_checkpoint(&mut self, own_step: bool) -> GroupCheckpoint { let docs = self .docs .iter() @@ -308,6 +316,7 @@ impl Session { // An edit inside clears redo; a failed group has to bring it back. redo: ds.history.redo.clone(), limit: ds.history.limit, + group_step: ds.history.group_step, }) .collect(); let before = GroupCheckpoint { @@ -320,6 +329,9 @@ impl Session { // The steps inside are dropped again below, so they mustn't push the oldest ones out. for ds in &mut self.docs { ds.history.limit = usize::MAX; + if own_step { + ds.history.group_step = None; + } } for ds in &before.docs { self.live_hold(ds.uid, true); @@ -346,6 +358,7 @@ impl Session { ds.page = saved.page; ds.history.undo = saved.undo; ds.history.limit = saved.limit; + ds.history.group_step = saved.group_step; } } self.active = before.active; diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index 4de47b11..ec3c094e 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -71,9 +71,9 @@ pub(crate) use host::{clean_shared, share_answer}; pub(crate) use join::start_join; pub use join::{can_remove_people, specs}; pub use types::{ - Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF, PEOPLE, - PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, join_target, - parse_color, + Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_PEERS, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF, + PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, + join_target, parse_color, }; /// The view a follow last applied: any other view means the user moved. @@ -196,8 +196,8 @@ pub struct Lives { /// Registers the app as the opener of `figcraft://` invite links (the desktop app on Windows /// installs one; see [`crate::invite_links`]). pub links: Option>, - /// The recorded online files changed since the app last saved the preferences (it saves them - /// at once: a crash must not lose an owner token). + /// The recorded online files or your team libraries changed since the app last saved the + /// preferences (it saves them at once: a crash must not lose an owner token). pub unsaved: bool, /// Servers that couldn't say what became of your files at the last `collab.files {refresh}`. pub servers: crate::online::Problems, diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index c50f0acb..757decbc 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -1,5 +1,5 @@ use super::host::{clean_hosting, clean_shared}; -use super::types::{Event, PageInfo, Role, SessionId, Status}; +use super::types::{Event, MAX_PEERS, PageInfo, Role, SessionId, SessionInfo, Status}; use super::{ Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal, ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, @@ -27,6 +27,24 @@ fn reset_for_reconnect(live: &mut Live) { } } +/// Joined, or joined again: who you are and who else is here (at most [`MAX_PEERS`] of them). +fn welcome_in(live: &mut Live, mut info: SessionInfo, peers: Vec) { + // The shared file's name and pages come with the document (the sync welcome). + if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) { + info.file_name = old.file_name.clone(); + info.pages = old.pages.clone(); + } + live.status = Status::Live; + live.info = Some(info); + live.peers = peers.into_iter().take(MAX_PEERS).map(|p| (p.client_id, p)).collect(); + live.presence.retain(|id, _| live.peers.contains_key(id)); + if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) { + live.unfollow(); + } + // Others need your presence again. + live.sent = None; +} + impl Session { /// The active document (`None`: no document is open). pub(super) fn tab(&self) -> Option { @@ -160,25 +178,14 @@ impl Session { } live.status = st; } - Event::Welcome { mut info, peers } => { - // The shared file's name and pages come with the document (the sync welcome). - if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) { - info.file_name = old.file_name.clone(); - info.pages = old.pages.clone(); - } - live.status = Status::Live; - live.info = Some(info); - live.peers = peers.into_iter().map(|p| (p.client_id, p)).collect(); - live.presence.retain(|id, _| live.peers.contains_key(id)); - if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) { - live.unfollow(); - } - // Others need your presence again. - live.sent = None; - } + Event::Welcome { info, peers } => welcome_in(live, info, peers), Event::PeerJoined(p) => { // Someone already here was renamed: no newcomer to announce. if !live.peers.contains_key(&p.client_id) { + if live.peers.len() >= MAX_PEERS { + log::warn!("live session: {MAX_PEERS} people already here; leaving out another"); + return; + } self.message = Some(format!("{} joined{behind}", p.name)); } live.peers.insert(p.client_id, p); diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs index 31ada26a..93455c0d 100644 --- a/crates/engine/src/sharing/live/types.rs +++ b/crates/engine/src/sharing/live/types.rs @@ -17,6 +17,8 @@ pub const DEFAULT_SERVER: &str = match option_env!("FIGCRAFT_DEFAULT_SERVER") { /// Most selected ids one presence update carries (the rest aren't shown to others). pub const MAX_SELECTION: usize = 10_000; +pub use figcraft_collab::message::MAX_PEERS; + /// Longest name shown to others (characters). pub const MAX_NAME: usize = Signal::MAX_NAME; diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index b34294a8..e9eca6a1 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -291,7 +291,7 @@ fn variables_import_caps_the_file_it_reads() { for bad in &refused { let before = (format!("{:?}", t.docs), t.active); let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string(); - assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}"); + assert!(["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), "{bad}: {e}"); assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}"); } // A file under the limit still imports. diff --git a/crates/render/src/bounds_memo_tests.rs b/crates/render/src/bounds_memo_tests.rs index 4d0b305b..44d7dc42 100644 --- a/crates/render/src/bounds_memo_tests.rs +++ b/crates/render/src/bounds_memo_tests.rs @@ -24,8 +24,9 @@ fn kept_paint_bounds_are_the_bounds_worked_out_afresh() { for id in &order { let n = d.node(*id).unwrap(); for depth in [0, 20] { - let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0; - assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name); + let world = d.world_transform(*id); + let kept = draw::bounds_within(&d, n, world, depth, None, Some(&memo)).0; + assert_eq!(kept, draw::local_paint_bounds(&d, n, world, depth, None), "{} at depth {depth}", n.name); } } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 4e1d864c..c2ab8d09 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -414,17 +414,41 @@ fn text_box_bounds(n: &Node) -> Rect { /// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children). /// Text that overflows its box counts where its glyphs are, so it isn't culled while visible; /// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`]. -pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option) -> Rect { - bounds_within(doc, n, depth, greek_scale, None).0 +/// `to_page` is the node's local → page map, for how far its effects reach (see [`local_reach`]). +pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, to_page: Affine, depth: usize, greek_scale: Option) -> Rect { + bounds_within(doc, n, to_page, depth, greek_scale, None).0 } +/// How far `reach` page units go in a layer's local units along x and y, under `to_page` (its +/// local → page map): effects are drawn at their size in page units however the layer is scaled, +/// so they reach further in a layer scaled down. A page square of half-size `reach` comes back as +/// the rows of the inverse map, however small the scale (capped where the sums would overflow); a +/// map that collapses the layer keeps `reach`. +fn local_reach(to_page: Affine, reach: f64) -> (f64, f64) { + let [a, b, c, d, _, _] = to_page.as_coeffs(); + let det = (a * d - b * c).abs(); + if reach <= 0.0 || !reach.is_finite() || !det.is_finite() || det == 0.0 { + return (reach, reach); + } + let along = |u: f64, v: f64| { + let r = (u.abs() + v.abs()) / det * reach; + if r.is_nan() { reach } else { r.min(MAX_LOCAL_REACH) } + }; + (along(d, c), along(b, a)) +} + +/// The most [`local_reach`] makes of a reach (local units): bounds that large are cut to the view, +/// and grown by it they stay finite. +const MAX_LOCAL_REACH: f64 = 1e150; + /// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many /// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on /// the depth they were asked at and aren't kept, and kept bounds are read only where their levels -/// still fit under the cap. +/// still fit under the cap. They are kept by node: one render meets a node under one `to_page`. pub(crate) fn bounds_within<'a>( doc: &'a Document, n: &Node, + to_page: Affine, depth: usize, greek_scale: Option, memo: Option<&Bounds<'a>>, @@ -437,8 +461,14 @@ pub(crate) fn bounds_within<'a>( { return (r, Some(levels)); } - let reach = n.paint_reach(); - let mut r = n.local_rect().inflate(reach, reach); + // Effects in page units, but a filled outline's spread in local units: as far as either goes. + let (fx, fy) = n.outline_spreads().fold(local_reach(to_page, n.effect_paint_reach()), |(x, y), (spread, page)| { + let (px, py) = local_reach(to_page, page); + (x.max(spread + px), y.max(spread + py)) + }); + let outline = n.outline_reach(); + let grow = |r: Rect| r.inflate(outline + fx, outline + fy); + let mut r = grow(n.local_rect()); let mut levels = Some(0); let clips = n.clip && n.kind.is_frame_like(); if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() { @@ -449,15 +479,15 @@ pub(crate) fn bounds_within<'a>( if !c.visible { continue; } - let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo); + let (cb, c_levels) = bounds_within(doc, c, to_page * c.transform, depth + 1, greek_scale, memo); below = below.zip(c_levels).map(|(b, l)| b.max(l)); let cb = figcraft_geom::transform_rect_bbox(c.transform, cb); kids = Some(kids.map_or(cb, |k| k.union(cb))); } // This layer's effects apply to what its children paint, past its box included. if let Some(k) = kids { - let e = n.effects_reach(); - r = r.union(if e > 0.0 { k.inflate(e, e) } else { k }); + let (ex, ey) = local_reach(to_page, n.effects_reach()); + r = r.union(if ex > 0.0 || ey > 0.0 { k.inflate(ex, ey) } else { k }); } levels = below.map(|b| b + 1); } else if !n.children.is_empty() { @@ -466,7 +496,7 @@ pub(crate) fn bounds_within<'a>( } // Text can overflow its box. if let NodeKind::Text(_) = n.kind { - r = r.union(text_box_bounds(n).inflate(reach, reach)); + r = r.union(grow(text_box_bounds(n))); if !greek_scale.is_some_and(|s| lod::greeked(n, s)) && let Some((l, dy)) = figcraft_text::layout_node(n) && let Some(ink) = l.ink_bounds() @@ -525,8 +555,14 @@ pub fn effect_margin(n: &Node) -> f64 { pub fn paint_bounds(doc: &Document, id: NodeId) -> Option { let n = doc.node(id)?; let m = effect_margin(n).abs(); - let local = local_paint_bounds(doc, n, 0, None); - Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), local).inflate(m, m)) + let world = doc.world_transform(id); + let local = local_paint_bounds(doc, n, world, 0, None); + Some(figcraft_geom::transform_rect_bbox(world, local).inflate(m, m)) +} + +/// The local → page map of a layer drawn at `xf` (local → device): the view's scale taken out. +fn to_page(ctx: &Ctx, xf: Affine) -> Affine { + if ctx.scale.is_finite() && ctx.scale > 0.0 { Affine::scale(1.0 / ctx.scale) * xf } else { xf } } fn needs_layer(n: &Node) -> bool { @@ -622,7 +658,7 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u } let xf = parent_xf * n.transform; let (pw, ph) = (pix.width(), pix.height()); - let local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0; + let local = bounds_within(ctx.doc, n, to_page(ctx, xf), 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0; let dev = device_rect(xf, local); // Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or // blur of millions of pixels) keep a sane layer: what lies further off reads as empty. @@ -974,7 +1010,7 @@ fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf: } let reach = |id: &NodeId| { let n = ctx.bounds.node(ctx.doc, *id)?; - let (local, levels) = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)); + let (local, levels) = bounds_within(ctx.doc, n, to_page(ctx, xf * n.transform), 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)); // Bounds the nesting cap cut short could miss a layer: anywhere then. if levels.is_none() { return Some(whole); diff --git a/crates/render/src/effects_behaviour_tests.rs b/crates/render/src/effects_behaviour_tests.rs index 9a91bebc..24b66a68 100644 --- a/crates/render/src/effects_behaviour_tests.rs +++ b/crates/render/src/effects_behaviour_tests.rs @@ -272,6 +272,101 @@ fn a_containers_effects_reach_past_what_its_children_paint() { } } +#[test] +fn effects_reach_as_far_under_a_scaled_or_turned_transform() { + use figcraft_geom::kurbo::Affine; + let shadow = |d: (f64, f64)| fx(EffectKind::DropShadow(sharp(d, 0.0))); + let spread = |s: f64| fx(EffectKind::DropShadow(sharp((0.0, 0.0), s))); + let turned = Affine::translate((150.0, 50.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4); + // (why, kind, transform, effects, a child's effects): effects are page units however a layer + // is transformed, as the renderer draws them, but for a shadow's spread of a filled outline, + // which grows the outline before it is transformed. + let rows = [ + ("a halved rectangle's shadow", NodeKind::Rectangle, Affine::scale(0.5), vec![shadow((0.0, 80.0))], None), + ("a turned rectangle's shadow", NodeKind::Rectangle, turned, vec![shadow((40.0, 40.0))], None), + ("a halved group's shadow", NodeKind::Group, Affine::scale(0.5), vec![shadow((0.0, 80.0))], Some(vec![])), + ("a shadow in a halved group", NodeKind::Group, Affine::scale(0.5), vec![], Some(vec![shadow((0.0, 80.0))])), + ("a flattened rectangle's shadow", NodeKind::Rectangle, Affine::scale_non_uniform(1.0, 0.25), vec![shadow((0.0, 80.0))], None), + ("a doubled rectangle's shadow", NodeKind::Rectangle, Affine::scale(2.0), vec![shadow((0.0, 80.0))], None), + ("a doubled rectangle's spread shadow", NodeKind::Rectangle, Affine::scale(2.0), vec![spread(20.0)], None), + ("a doubled star's spread shadow", NodeKind::Star { count: 5, inner_ratio: 0.4 }, Affine::scale(2.0), vec![spread(10.0)], None), + ("a doubled group's spread shadow", NodeKind::Group, Affine::scale(2.0), vec![spread(20.0)], Some(vec![])), + ("a turned star's spread shadow", NodeKind::Star { count: 5, inner_ratio: 0.4 }, turned, vec![spread(20.0)], None), + ]; + for (why, kind, xf, effects, child_fx) in rows { + // `room`: a clear drop shadow wider than the view on each layer, so none can be cut: what + // they should draw. + let scene = |room: bool| { + let clear = fx(EffectKind::DropShadow(Shadow { color: Color::TRANSPARENT, radius: 400.0, ..sharp((0.0, 0.0), 0.0) })); + let with_room = |mut e: Vec| { + if room { + e.push(clear.clone()); + } + e + }; + let (mut d, p) = page(); + let n = d.create(p, kind.clone(), "N", 0.0, 0.0, 100.0, 100.0).unwrap(); + if let Some(cfx) = &child_fx { + let r = rect(&mut d, n, (0.0, 0.0, 100.0, 100.0), vec![Paint::solid(hex("FF0000"))]); + d.get_mut(r).unwrap().effects = with_room(cfx.clone()); + } + let node = d.get_mut(n).unwrap(); + node.fills = vec![Paint::solid(hex("FF0000"))]; + node.transform = xf; + node.effects = with_room(effects.clone()); + (d, n) + }; + let ((d, n), (truth, _)) = (scene(false), scene(true)); + let v = View { origin: (-100.0, -100.0), scale: 1.0, width: 400, height: 400 }; + let (a, b) = ( + render_view_single(&d, page_of(&d), &v, &Options::default()).unwrap(), + render_view_single(&truth, page_of(&truth), &v, &Options::default()).unwrap(), + ); + let worst = a.data().iter().zip(b.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + let ink = |p: Pixmap| p.pixels().iter().map(|c| u64::from(c.alpha())).sum::(); + let (got, all) = (ink(render_node(&d, n, 1.0).unwrap()), ink(render_node(&truth, page_child(&truth), 1.0).unwrap())); + assert!(worst <= 1, "{why}: the canvas draws {worst} levels off"); + assert!(got * 1000 >= all * 999, "{why}: the export holds {got} of {all} of the paint"); + } +} + +#[test] +fn effects_reach_as_far_under_a_tiny_scale() { + // A rectangle `w` units wide drawn 100 × 100 on the page casts the shadow a 100 × 100 one does: + // shadows are page units. + let scene = |w: f64| { + let (mut d, p) = page(); + let r = rect(&mut d, p, (0.0, 0.0, w, w), vec![Paint::solid(hex("FF0000"))]); + let n = d.get_mut(r).unwrap(); + n.transform = figcraft_geom::kurbo::Affine::scale(100.0 / w); + n.effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 80.0), 0.0)))]; + d + }; + let v = View { origin: (-100.0, -100.0), scale: 1.0, width: 400, height: 400 }; + let truth = scene(100.0); + let b = render_view_single(&truth, page_of(&truth), &v, &Options::default()).unwrap(); + for w in [1e6, 1e8, 1e9] { + let d = scene(w); + let a = render_view_single(&d, page_of(&d), &v, &Options::default()).unwrap(); + let worst = a.data().iter().zip(b.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + assert!(worst <= 1, "{w} units wide: the canvas draws {worst} levels off"); + } +} + +#[test] +fn a_spread_counts_once_in_an_export() { + // A 100 × 100 rectangle with a shadow spread by 20 all round, unscaled and doubled. + for (scale, side) in [(1.0, 140), (2.0, 280)] { + let (mut d, p) = page(); + let r = rect(&mut d, p, (0.0, 0.0, 100.0, 100.0), vec![Paint::solid(hex("FF0000"))]); + let n = d.get_mut(r).unwrap(); + n.transform = figcraft_geom::kurbo::Affine::scale(scale); + n.effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 0.0), 20.0)))]; + let px = render_node(&d, r, 1.0).unwrap(); + assert_eq!((px.width(), px.height()), (side, side), "at scale {scale}"); + } +} + fn page_of(d: &Document) -> NodeId { d.first_page().unwrap() } diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 0b41c0f5..4a988de6 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -946,6 +946,9 @@ status = "draft — needs native review" "Not in this font" = "غير متاح في هذا الخط" "Not properties, kept as written: {names}" = "ليست خصائص، أُبقيت كما كُتبت: {names}" "Not read yet" = "لم تُقرأ بعد" +"Check for updates" = "التحقق من التحديثات" +"Not checked yet" = "لم يُتحقق منها بعد" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "يستخدم هذا الملف مكتبات فريق على خوادم لم تستخدمها هنا. لا يُتصل بها حتى تتحقق منها." "Not shared" = "غير مشارك" "Note {n}" = "الملاحظة {n}" "Nothing changed since this version." = "لم يتغير شيء منذ هذا الإصدار." diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index f7e0cf4d..6e0e9fc7 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -914,6 +914,9 @@ status = "draft — needs native review" "Not in this font" = "এই ফন্টে নেই" "Not properties, kept as written: {names}" = "প্রপার্টি নয়, যেমনটি লেখা হয়েছে তেমনি রাখা হয়েছে: {names}" "Not read yet" = "এখনো পড়া হয়নি" +"Check for updates" = "আপডেট দেখুন" +"Not checked yet" = "এখনো যাচাই করা হয়নি" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "এই ফাইলটি এমন সার্ভারের টিম লাইব্রেরি ব্যবহার করে যা আপনি এখানে ব্যবহার করেননি। আপনি যাচাই না করা পর্যন্ত সেগুলোর সাথে যোগাযোগ করা হয় না।" "Not shared" = "শেয়ার করা হয়নি" "Note {n}" = "নোট {n}" "Nothing changed since this was marked ready for dev." = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কিছু পরিবর্তিত হয়নি।" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 06a9fc4b..adfb45b7 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -896,6 +896,9 @@ status = "draft — needs native review" "Not in this font" = "Není v tomto písmu" "Not properties, kept as written: {names}" = "Nejde o vlastnosti, ponecháno beze změny: {names}" "Not read yet" = "Dosud nepřečteno" +"Check for updates" = "Zkontrolovat aktualizace" +"Not checked yet" = "Dosud nezkontrolováno" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tento soubor používá týmové knihovny na serverech, které jste tu dosud nepoužili. Nekontaktují se, dokud je nezkontrolujete." "Not shared" = "Nesdíleno" "Note {n}" = "Poznámka {n}" "Nothing changed since this was marked ready for dev." = "Od označení jako připravené pro vývoj se nic nezměnilo." diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 33c7e396..aa9d040b 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -870,6 +870,9 @@ status = "draft — needs native review" "Not in this font" = "Nicht in dieser Schrift" "Not properties, kept as written: {names}" = "Keine Eigenschaften, unverändert übernommen: {names}" "Not read yet" = "Noch nicht gelesen" +"Check for updates" = "Nach Updates suchen" +"Not checked yet" = "Noch nicht geprüft" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Diese Datei nutzt Teambibliotheken auf Servern, die Sie hier noch nicht verwendet haben. Diese Server werden erst kontaktiert, wenn Sie die Bibliotheken prüfen." "Not shared" = "Nicht geteilt" "Note {n}" = "Notiz {n}" "Nothing changed since this version." = "Seit dieser Version hat sich nichts geändert." diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e76aa0e1..7529d93b 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -221,6 +221,7 @@ name = "English" "Can comment" = "Can comment" "Check" = "Check" "Check for Updates" = "Check for Updates" +"Check for updates" = "Check for updates" "Check for updates…" = "Check for updates…" "Checking for updates…" = "Checking for updates…" "Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" @@ -953,6 +954,7 @@ name = "English" "Not in this font" = "Not in this font" "Not now" = "Not now" "Not properties, kept as written: {names}" = "Not properties, kept as written: {names}" +"Not checked yet" = "Not checked yet" "Not read yet" = "Not read yet" "Not shared" = "Not shared" "Note {n}" = "Note {n}" @@ -1502,6 +1504,7 @@ name = "English" "This branch was merged or archived." = "This branch was merged or archived." "This computer only" = "This computer only" "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index f95ecb03..95743f10 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -940,6 +940,9 @@ status = "draft — needs native review" "Not in this font" = "No está en esta fuente" "Not properties, kept as written: {names}" = "No son propiedades, se quedan tal cual: {names}" "Not read yet" = "Aún no leída" +"Check for updates" = "Buscar actualizaciones" +"Not checked yet" = "Aún sin comprobar" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Este archivo usa bibliotecas de equipo en servidores que no has usado aquí. No se contactan hasta que las compruebes." "Not shared" = "Sin compartir" "Note {n}" = "Nota {n}" "Nothing changed since this version." = "Nada cambió desde esta versión." diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 1a9af63e..851583ef 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -916,6 +916,9 @@ status = "draft — needs native review" "Not in this font" = "Ei tässä fontissa" "Not properties, kept as written: {names}" = "Eivät ole ominaisuuksia, säilytetään sellaisinaan: {names}" "Not read yet" = "Ei vielä luettu" +"Check for updates" = "Tarkista päivitykset" +"Not checked yet" = "Ei vielä tarkistettu" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tämä tiedosto käyttää tiimikirjastoja palvelimilla, joita et ole käyttänyt täällä. Niihin ei oteta yhteyttä ennen kuin tarkistat ne." "Not shared" = "Ei jaettu" "Note {n}" = "Huomautus {n}" "Nothing changed since this was marked ready for dev." = "Mikään ei ole muuttunut kehitysvalmiiksi merkitsemisen jälkeen." diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 932c1d72..894f4eea 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -914,6 +914,9 @@ status = "draft — needs native review" "Not in this font" = "Absent de cette police" "Not properties, kept as written: {names}" = "Pas des propriétés, conservés tels quels : {names}" "Not read yet" = "Pas encore lu" +"Check for updates" = "Rechercher des mises à jour" +"Not checked yet" = "Pas encore vérifiée" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ce fichier utilise des bibliothèques d’équipe sur des serveurs que vous n’avez pas utilisés ici. Ils ne sont contactés que lorsque vous les vérifiez." "Not shared" = "Non partagé" "Note {n}" = "Note {n}" "Nothing changed since this version." = "Rien n’a changé depuis cette version." diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index b3bd1bc9..56d09e3a 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -903,6 +903,9 @@ status = "draft — needs native review" "Not in this font" = "Tidak ada di font ini" "Not properties, kept as written: {names}" = "Bukan properti, dibiarkan apa adanya: {names}" "Not read yet" = "Belum dibaca" +"Check for updates" = "Periksa pembaruan" +"Not checked yet" = "Belum diperiksa" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "File ini memakai pustaka tim di server yang belum pernah Anda gunakan di sini. Server itu tidak dihubungi sampai Anda memeriksanya." "Not shared" = "Tidak dibagikan" "Note {n}" = "Catatan {n}" "Nothing changed since this was marked ready for dev." = "Tidak ada perubahan sejak ditandai siap untuk dev." diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 299d322d..84eccc63 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -884,6 +884,9 @@ status = "draft — needs native review" "Not in this font" = "Non presente in questo font" "Not properties, kept as written: {names}" = "Non sono proprietà, mantenute come scritte: {names}" "Not read yet" = "Non ancora letto" +"Check for updates" = "Verifica aggiornamenti" +"Not checked yet" = "Non ancora verificata" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Questo file usa librerie del team su server che non hai usato qui. Non vengono contattati finché non le verifichi." "Not shared" = "Non condiviso" "Note {n}" = "Nota {n}" "Nothing changed since this was marked ready for dev." = "Nulla è cambiato da quando è stato segnato come pronto per lo sviluppo." diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 298722c4..6cc8ad2f 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -907,6 +907,9 @@ status = "draft — needs native review" "Not in this font" = "このフォントにはない" "Not properties, kept as written: {names}" = "プロパティではないため記述どおりに保持:{names}" "Not read yet" = "未読" +"Check for updates" = "アップデートを確認" +"Not checked yet" = "未確認" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "このファイルは、ここで使ったことのないサーバー上のチームライブラリを使う。確認するまでそれらのサーバーには接続しない。" "Not shared" = "共有していない" "Note {n}" = "メモ {n}" "Nothing changed since this version." = "このバージョンから変更なし。" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 35f8825c..4014462b 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -923,6 +923,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Not in this font" = "ამ შრიფტში არ არის" "Not properties, kept as written: {names}" = "არ არის თვისებები, დარჩა დაწერილი სახით: {names}" "Not read yet" = "ჯერ არ არის წაკითხული" +"Check for updates" = "განახლებების შემოწმება" +"Not checked yet" = "ჯერ არ არის შემოწმებული" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ეს ფაილი იყენებს გუნდის ბიბლიოთეკებს სერვერებზე, რომლებიც აქ არ გამოგიყენებიათ. მათ არ დავუკავშირდებით, სანამ არ შეამოწმებთ." "Not shared" = "არ არის გაზიარებული" "Note {n}" = "შენიშვნა {n}" "Nothing changed since this version." = "ამ ვერსიის შემდეგ არაფერი შეცვლილა." diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 5524afc6..681eb524 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -866,6 +866,9 @@ status = "draft — needs native review" "Not in this font" = "이 글꼴에 없음" "Not properties, kept as written: {names}" = "속성이 아니므로 작성된 대로 유지: {names}" "Not read yet" = "아직 읽지 않음" +"Check for updates" = "업데이트 확인" +"Not checked yet" = "아직 확인 안 함" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "이 파일은 여기서 사용한 적 없는 서버의 팀 라이브러리를 사용합니다. 확인하기 전까지는 해당 서버에 연결하지 않습니다." "Not shared" = "공유 안 함" "Note {n}" = "메모 {n}" "Nothing changed since this version." = "이 버전 이후 변경된 내용 없음." diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 4f9b2732..89a99334 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -916,6 +916,9 @@ status = "draft — needs native review" "Not in this font" = "Niet in dit lettertype" "Not properties, kept as written: {names}" = "Geen eigenschappen, ongewijzigd overgenomen: {names}" "Not read yet" = "Nog niet gelezen" +"Check for updates" = "Controleren op updates" +"Not checked yet" = "Nog niet gecontroleerd" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Dit bestand gebruikt teambibliotheken op servers die je hier nog niet hebt gebruikt. Er wordt pas contact mee gemaakt als je ze controleert." "Not shared" = "Niet gedeeld" "Note {n}" = "Notitie {n}" "Nothing changed since this was marked ready for dev." = "Er is niets gewijzigd sinds dit was gemarkeerd als ready for dev." diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 7b60c4c9..2081b1cd 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -918,6 +918,9 @@ name = "Polski" "Not in this font" = "Brak w tej czcionce" "Not properties, kept as written: {names}" = "Nie są to właściwości, zachowano bez zmian: {names}" "Not read yet" = "Jeszcze nieprzeczytane" +"Check for updates" = "Sprawdź aktualizacje" +"Not checked yet" = "Jeszcze nie sprawdzono" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ten plik korzysta z bibliotek zespołu na serwerach, których tu jeszcze nie używano. Nie są kontaktowane, dopóki ich nie sprawdzisz." "Not shared" = "Nieudostępnione" "Note {n}" = "Notatka {n}" "Nothing changed since this was marked ready for dev." = "Nic się nie zmieniło od czasu oznaczenia jako gotowe do wdrożenia." diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 64d3dc13..4033ecc8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -865,6 +865,9 @@ status = "draft — needs native review" "Not in this font" = "Não existe nesta fonte" "Not properties, kept as written: {names}" = "Não são propriedades, mantidos como escritos: {names}" "Not read yet" = "Ainda não lido" +"Check for updates" = "Verificar atualizações" +"Not checked yet" = "Ainda não verificada" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Este arquivo usa bibliotecas de equipe em servidores que você não usou aqui. Eles só são contatados quando você os verifica." "Not shared" = "Não compartilhado" "Note {n}" = "Nota {n}" "Nothing changed since this version." = "Nada mudou desde esta versão." diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 9cb88591..01c2813a 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -918,6 +918,9 @@ name = "Русский" "Not in this font" = "Нет в этом шрифте" "Not properties, kept as written: {names}" = "Не свойства, оставлены как есть: {names}" "Not read yet" = "Ещё не прочитано" +"Check for updates" = "Проверить обновления" +"Not checked yet" = "Ещё не проверено" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Этот файл использует командные библиотеки на серверах, которые вы здесь не использовали. К ним не обращаются, пока вы их не проверите." "Not shared" = "Не опубликовано" "Note {n}" = "Заметка {n}" "Nothing changed since this was marked ready for dev." = "С момента отметки «Готово к разработке» изменений нет." diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 7832111b..549b7ff8 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -940,6 +940,9 @@ name = "Svenska" "Not in this font" = "Finns inte i det här typsnittet" "Not properties, kept as written: {names}" = "Inte egenskaper, behölls som de skrevs: {names}" "Not read yet" = "Inte läst än" +"Check for updates" = "Sök efter uppdateringar" +"Not checked yet" = "Inte kontrollerat än" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Den här filen använder teambibliotek på servrar som du inte har använt här. De kontaktas inte förrän du kontrollerar dem." "Not shared" = "Inte delad" "Note {n}" = "Anteckning {n}" "Nothing changed since this version." = "Ingenting har ändrats sedan den här versionen." diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index f17434ff..4af40498 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -910,6 +910,9 @@ status = "draft — needs native review" "Not in this font" = "ไม่มีในฟอนต์นี้" "Not properties, kept as written: {names}" = "ไม่ใช่พร็อพเพอร์ตี คงไว้ตามที่เขียน: {names}" "Not read yet" = "ยังไม่ได้อ่าน" +"Check for updates" = "ตรวจหาอัปเดต" +"Not checked yet" = "ยังไม่ได้ตรวจ" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ไฟล์นี้ใช้ไลบรารีทีมบนเซิร์ฟเวอร์ที่คุณยังไม่เคยใช้ที่นี่ จะไม่มีการติดต่อเซิร์ฟเวอร์เหล่านั้นจนกว่าคุณจะตรวจ" "Not shared" = "ไม่ได้แชร์" "Note {n}" = "โน้ต {n}" "Nothing changed since this version." = "ไม่มีอะไรเปลี่ยนตั้งแต่เวอร์ชันนี้" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index ef525a8b..6f84aa37 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -900,6 +900,9 @@ name = "Türkçe" "Not in this font" = "Bu yazı tipinde yok" "Not properties, kept as written: {names}" = "Özellik değil, yazıldığı gibi korundu: {names}" "Not read yet" = "Henüz okunmadı" +"Check for updates" = "Güncellemeleri denetle" +"Not checked yet" = "Henüz denetlenmedi" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Bu dosya, burada kullanmadığınız sunuculardaki ekip kitaplıklarını kullanıyor. Siz denetleyene kadar bu sunuculara bağlanılmaz." "Not shared" = "Paylaşılmadı" "Note {n}" = "Not {n}" "Nothing changed since this was marked ready for dev." = "Geliştirmeye hazır olarak işaretlendiğinden beri hiçbir şey değişmedi." diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 1f3ec5e9..e729d8cc 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -885,6 +885,9 @@ status = "draft — needs native review" "Not in this font" = "Không có trong phông chữ này" "Not properties, kept as written: {names}" = "Không phải thuộc tính, được giữ nguyên như đã viết: {names}" "Not read yet" = "Chưa đọc" +"Check for updates" = "Kiểm tra cập nhật" +"Not checked yet" = "Chưa kiểm tra" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tệp này dùng thư viện nhóm trên các máy chủ bạn chưa dùng ở đây. Các máy chủ đó không được liên hệ cho đến khi bạn kiểm tra." "Not shared" = "Chưa chia sẻ" "Note {n}" = "Ghi chú {n}" "Nothing changed since this was marked ready for dev." = "Chưa có gì thay đổi kể từ khi được đánh dấu sẵn sàng cho dev." diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d5934657..ae545b2f 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -871,6 +871,9 @@ status = "draft — needs native review" "Not in this font" = "此字体不支持" "Not properties, kept as written: {names}" = "不是属性,按原样保留:{names}" "Not read yet" = "尚未读取" +"Check for updates" = "检查更新" +"Not checked yet" = "尚未检查" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "此文件使用了你在此处未用过的服务器上的团队库。在你检查之前,不会连接这些服务器。" "Not shared" = "未分享" "Note {n}" = "备注 {n}" "Nothing changed since this version." = "自此版本以来没有任何更改。" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0fe1c315..ae39c7e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -374,6 +374,9 @@ mod builder_poly_tests; #[cfg(test)] mod outline_tests; +#[cfg(test)] +mod selection_cache_tests; + #[cfg(test)] mod wheel_zoom_tests; diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index a0dc2945..154a4de0 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -71,7 +71,30 @@ pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) { /// The slots of the selected components and instances (and a slot being dropped onto): a dashed /// outline and the slot's name above its top-left corner, like a frame label (never over the /// content). -pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option) { +pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Arc, sel: &[NodeId], drop: Option) { + let key = [drop.map_or(0, |d| d.0.wrapping_add(1))]; + let slots = crate::selection_cache::cached(painter.ctx(), "slots", doc, sel, &key, || slots_shown(doc, sel, drop)); + for (id, name) in slots { + let Some(q) = node_quad(app, doc, id) else { continue }; + if !Rect::from_points(&q).intersects(app.canvas.rect) { + continue; + } + painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); + // The label, when the slot is wide enough on screen to hold it. + let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT); + let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0)); + if (q[1] - q[0]).length() < w { + continue; + } + let at = q[0] - vec2(0.0, h + 2.0); + crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT); + painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT); + } +} + +/// The slots [`slot_badges`] shows, with their names: those of the selected components and +/// instances (and of the ones the selected layers are in), and the slot being dropped onto. +fn slots_shown(doc: &Document, sel: &[NodeId], drop: Option) -> Vec<(NodeId, String)> { use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of}; let mut roots: Vec = Vec::new(); for id in sel { @@ -90,22 +113,7 @@ pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, { slots.push((d, name)); } - for (id, name) in slots { - let Some(q) = node_quad(app, doc, id) else { continue }; - if !Rect::from_points(&q).intersects(app.canvas.rect) { - continue; - } - painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); - // The label, when the slot is wide enough on screen to hold it. - let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT); - let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0)); - if (q[1] - q[0]).length() < w { - continue; - } - let at = q[0] - vec2(0.0, h + 2.0); - crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT); - painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT); - } + slots } /// The instance an Alt-drop of a component from the Assets panel swaps: tinted and outlined in @@ -197,16 +205,13 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option Vec { match outline_shape(app, doc, id) { - Some(lines) => { - for l in lines { - painter.add(Shape::line(l, s)); - } - } - None => { - if let Some(q) = node_quad(app, doc, id) { - quad(painter, q, s); - } - } + Some(lines) => lines.into_iter().map(|l| Shape::line(l, s)).collect(), + None => node_quad(app, doc, id).map(|q| Shape::closed_line(q.to_vec(), s)).into_iter().collect(), } } diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 5e932324..e34dc341 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,5 +1,7 @@ +use std::sync::Arc; + use super::builder::{builder_overlay, hatched, region_hatch}; -use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight}; +use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, outline_shapes, quad, slot_badges, stroke, swap_highlight}; use super::rulers::{boards, fmt, rulers}; use super::{ Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId, @@ -234,20 +236,51 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do painter.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } +/// What the selection's overlay draws that only the document, the selection, the view and the +/// overlay colour decide: kept for the frames that show the same ([`crate::selection_cache`]). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct SelectionGeometry { + /// The outline colour: the component colour when any selected layer is in one. + pub(super) col: Color32, + /// Every selected layer's outline on screen. + pub(super) outlines: Arc>, + /// The transform box (page space) and its width and height. + bbox: Option<[Point; 4]>, + size: Option<(f64, f64)>, + /// A selected layer is locked (no handles). + locked: bool, +} + +pub(super) fn selection_geometry(app: &FigApp, painter: &egui::Painter, doc: &Arc, sel: &[NodeId]) -> SelectionGeometry { + let green = accent(painter); + let mapping = [app.to_screen(0.0, 0.0), app.to_screen(1.0, 1.0)].map(crate::selection_cache::bits); + let extra = [mapping[0][0], mapping[0][1], mapping[1][0], mapping[1][1], u64::from(u32::from_le_bytes(green.to_array()))]; + crate::selection_cache::cached(painter.ctx(), "selection overlay", doc, sel, &extra, || { + let col = if sel.iter().any(|i| outline_colour(doc, *i, green) == COMPONENT) { COMPONENT } else { green }; + let outlines = sel.iter().flat_map(|id| outline_shapes(app, doc, *id, stroke(col, 1.0))).collect(); + SelectionGeometry { + col, + outlines: Arc::new(outlines), + bbox: selection_box(doc, sel), + size: selection_frame(doc, sel).map(|(_, w, h)| (w, h)), + locked: sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)), + } + }) +} + /// The selection's outlines, transform box and handles; returns the size label's box. fn selection_overlay( app: &FigApp, painter: &egui::Painter, ds: &figcraft_engine::DocState, - doc: &Document, + doc: &Arc, sel: &[NodeId], zoom: f64, ) -> Option { let mut size_label = None; - let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) }; - for id in sel { - draw_outline(app, painter, doc, *id, stroke(col, 1.0)); - } + let geo = selection_geometry(app, painter, doc, sel); + let col = geo.col; + painter.extend(geo.outlines.iter().cloned()); // Constraint guides belong to the normal selection overlay: not while the Shape Builder // reshapes by regions, nor while measuring to another layer (Alt), whose lines take over. if app.session.tool.tool != ToolId::ShapeBuilder && app.session.tool.overlay.measure_box.is_none() { @@ -255,8 +288,8 @@ fn selection_overlay( } // The shape builder reshapes by regions: no transform box. if app.session.tool.tool != ToolId::ShapeBuilder - && let Some(b) = selection_box(doc, sel) - && let Some((_, w, h)) = selection_frame(doc, sel) + && let Some(b) = geo.bbox + && let Some((w, h)) = geo.size { let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line)); // A selected connector's handles are its two ends (drag one onto another object). @@ -275,12 +308,7 @@ fn selection_overlay( // Mode: no handles either. // Crop mode draws its own marks on the box instead (`handles::crop_marks`). let cropping = app.ui.crop.is_some(); - let handles = if cropping - || measuring - || app.session.tool.dev_mode - || connector.is_some() - || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) - { + let handles = if cropping || measuring || app.session.tool.dev_mode || connector.is_some() || geo.locked { 0 } else if is_line { 2 diff --git a/crates/ui-egui/src/canvas/selection_cache_tests.rs b/crates/ui-egui/src/canvas/selection_cache_tests.rs new file mode 100644 index 00000000..ca20e544 --- /dev/null +++ b/crates/ui-egui/src/canvas/selection_cache_tests.rs @@ -0,0 +1,178 @@ +//! The selection overlay's geometry and the Design panel's values are kept for the frames that +//! show the same (#1571), made again on every input they show (an edit, the selection, zoom, pan, +//! the canvas's place, the theme's overlay green), and the same as when made afresh; what they +//! don't keep (the hover and drop-target highlight) shows on the next frame. + +use std::sync::Arc; + +use egui::{Color32, Context, LayerId, Painter, Rect, pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use super::overlays::{SelectionGeometry, overlays, selection_geometry}; +use crate::theme::{Tokens, set_canvas_page}; +use crate::{FigApp, NoServices}; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// Three rectangles (the middle one rounded, outlined along its corners) selected, and a fourth +/// that isn't, on a light page. +fn setup() -> (FigApp, Context, Vec) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for x in [0, 80, 160, 240] { + ids.push(id(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 50})).unwrap())); + } + let radii = json!({"topLeftRadius": 12, "topRightRadius": 12, "bottomRightRadius": 12, "bottomLeftRadius": 12}); + s.execute("node.set", &json!({"id": ids[1], "props": radii})).unwrap(); + s.execute("select.set", &json!({"ids": ids[..3]})).unwrap(); + let mut app = FigApp::new(s, Box::new(NoServices)); + app.canvas.rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let ctx = Context::default(); + set_canvas_page(&ctx, Color32::WHITE); + (app, ctx, ids) +} + +fn geometry(app: &FigApp, ctx: &Context) -> SelectionGeometry { + let ds = app.session.ds().unwrap(); + selection_geometry(app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &ds.doc.clone(), &ds.selection.clone()) +} + +/// The geometry made with nothing kept, on a page of colour `page`. +fn fresh(app: &FigApp, page: Color32) -> SelectionGeometry { + let ctx = Context::default(); + set_canvas_page(&ctx, page); + geometry(app, &ctx) +} + +fn kept(a: &SelectionGeometry, b: &SelectionGeometry) -> bool { + Arc::ptr_eq(&a.outlines, &b.outlines) +} + +#[test] +fn an_unchanged_frame_reuses_the_selection_geometry() { + let (app, ctx, _) = setup(); + let first = geometry(&app, &ctx); + assert_eq!(first.outlines.len(), 3); + assert!(kept(&first, &geometry(&app, &ctx))); + assert_eq!(first, fresh(&app, Color32::WHITE)); +} + +#[test] +fn a_kept_value_does_not_keep_its_document_alive() { + let (app, ctx, _) = setup(); + let doc = app.session.ds().unwrap().doc.clone(); + let holders = Arc::strong_count(&doc); + geometry(&app, &ctx); + assert_eq!(Arc::strong_count(&doc), holders); +} + +type Change = Box; + +#[test] +fn every_input_the_geometry_shows_makes_it_again() { + let (mut app, ctx, ids) = setup(); + let (a, b) = (ids[0], ids[1]); + let changes: Vec<(&str, Change)> = vec![ + ( + "an edit", + Box::new(move |app: &mut FigApp, _: &Context| { + app.set_props(&[b], json!({"x": 90})); + }), + ), + ( + "the selection", + Box::new(move |app: &mut FigApp, _: &Context| { + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + }), + ), + ("zoom", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.zoom *= 2.0)), + ("pan", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.cx += 30.0)), + ("the canvas moving", Box::new(|app: &mut FigApp, _: &Context| app.canvas.rect = app.canvas.rect.translate(vec2(40.0, 0.0)))), + ("the theme", Box::new(|_: &mut FigApp, ctx: &Context| set_canvas_page(ctx, Color32::BLACK))), + ]; + let mut page = Color32::WHITE; + for (what, change) in changes { + let before = geometry(&app, &ctx); + change(&mut app, &ctx); + if what == "the theme" { + page = Color32::BLACK; + assert_ne!(crate::theme::canvas_accent(&ctx), before.col, "a dark page takes the other green"); + } + let after = geometry(&app, &ctx); + assert!(!kept(&before, &after), "{what}"); + assert_ne!(before, after, "{what}"); + assert_eq!(after, fresh(&app, page), "{what}: as if nothing were kept"); + assert!(kept(&after, &geometry(&app, &ctx)), "{what}: kept again"); + } +} + +/// How many shapes one frame of the canvas overlays draws. +fn drawn(app: &FigApp, ctx: &Context) -> usize { + let mut out = ctx.run_ui(Default::default(), |ui| { + overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light()); + }); + out.textures_delta.clear(); + out.shapes.len() +} + +#[test] +fn the_hover_and_drop_highlights_show_over_kept_geometry() { + let (mut app, ctx, ids) = setup(); + let other = ids[3]; + let plain = drawn(&app, &ctx); + let first = geometry(&app, &ctx); + app.session.ds_mut().unwrap().hover = Some(other); + assert_eq!(drawn(&app, &ctx), plain + 1, "the hovered layer's outline"); + assert!(kept(&first, &geometry(&app, &ctx)), "the hover isn't part of the selection's geometry"); + app.session.ds_mut().unwrap().hover = None; + app.session.tool.overlay.drop_target = Some(other); + assert_eq!(drawn(&app, &ctx), plain + 1, "the drop target's outline"); + app.session.tool.overlay.drop_target = None; + assert_eq!(drawn(&app, &ctx), plain); +} + +#[test] +fn the_design_panels_instance_facts_follow_edits_and_the_selection() { + let mut s = Session::new(); + let f = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + id(s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap()); + let main = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); + let inst = id(s.execute("instance.create", &json!({"component": main, "x": 300, "y": 0})).unwrap()); + let child = s.doc().unwrap().node(inst).unwrap().children[0]; + let ctx = Context::default(); + let facts = |s: &Session| { + let ds = s.ds().unwrap(); + crate::right::in_instance(&ctx, &ds.doc.clone(), &ds.selection.clone()) + }; + let facts_of = |s: &mut Session, ids: Value| { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let f = facts(s); + (f.any, f.nested) + }; + assert_eq!(facts_of(&mut s, json!([main])), (false, false)); + assert_eq!(facts_of(&mut s, json!([inst])), (true, false)); + assert_eq!(facts_of(&mut s, json!([inst, child])), (true, true)); + assert_eq!(facts_of(&mut s, json!([inst])), (true, false)); + s.execute("component.detach", &json!({"ids": [inst]})).unwrap(); + assert!(s.doc().unwrap().node(inst).is_some(), "detaching keeps the layer"); + assert_eq!(s.ds().unwrap().selection, vec![inst], "and the selection"); + assert_eq!(facts(&s), crate::right::InInstance { any: false, nested: false }, "an edit"); +} + +#[test] +fn the_position_fields_follow_edits_and_the_selection() { + let (mut app, ctx, ids) = setup(); + let x = |app: &FigApp| { + let ds = app.session.ds().unwrap(); + crate::right::position_values(&ctx, &ds.doc.clone(), &ds.selection.clone()).x + }; + assert_eq!(x(&app), None, "mixed"); + app.session.execute("select.set", &json!({"ids": [ids[1]]})).unwrap(); + assert_eq!(x(&app), Some(80.0), "the selection"); + app.set_props(&[ids[1]], json!({"x": 90})); + assert_eq!(x(&app), Some(90.0), "an edit"); +} diff --git a/crates/ui-egui/src/component_set_narrow_tests.rs b/crates/ui-egui/src/component_set_narrow_tests.rs new file mode 100644 index 00000000..ba0dcce2 --- /dev/null +++ b/crates/ui-egui/src/component_set_narrow_tests.rs @@ -0,0 +1,56 @@ +//! A component set's Properties at the narrowest right panel (240 px, the default), driven through +//! egui headless: every value field and every + and − button stays inside the panel, and the +//! values wrap onto more lines instead of running off its edge (found while retaking the README +//! screenshots). + +use egui::accesskit::Node; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::component_swap_default_tests::tree; +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// The right edge of what a panel row may use: the window's, less the panel's right inset. +const RIGHT: f64 = 1440.0 - crate::widgets::PAD_RIGHT as f64; + +fn label(n: &Node) -> String { + n.label().or(n.value()).unwrap_or_default().to_string() +} + +#[test] +fn a_component_sets_properties_fit_the_narrowest_panel() { + let mut s = Session::new(); + let mut variants = Vec::new(); + let names = ["Style=Primary, State=Default", "Style=Tonal, State=Hover", "Style=Outline, State=Disabled", "Style=Primary, State=Hover"]; + for (k, name) in names.iter().enumerate() { + let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap()); + variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap())); + } + let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap()); + s.execute("component.addProperty", &json!({"id": set, "name": "Show icon", "type": "boolean", "default": true})).unwrap(); + s.execute("component.addProperty", &json!({"id": set, "name": "Label", "type": "text", "default": "Continue"})).unwrap(); + s.execute("select.set", &json!({"ids": [set]})).unwrap(); + let mut u = Ui::new(s); + assert_eq!(u.app.ui.right_width, crate::PANEL_DEFAULT); + u.ctx.enable_accesskit(); + let nodes = tree(&mut u); + let find = |text: &str| -> Vec<&Node> { nodes.iter().filter(|n| label(n) == text && n.bounds().is_some()).collect() }; + for text in ["Primary", "Tonal", "Outline", "Default", "Hover", "Disabled", "Delete property", "Add variant with a new value"] { + let found = find(text); + assert!(!found.is_empty(), "{text} is in the panel"); + for n in found { + let b = n.bounds().unwrap_or_default(); + assert!(b.x1 <= RIGHT + 0.5, "{text} ends at {} past the panel's {RIGHT}", b.x1); + } + } + assert_eq!(find("Delete property").len(), 4, "a − for each property"); + assert_eq!(find("Add variant with a new value").len(), 2, "a + for each variant property"); + // Two values to a line at this width: Outline goes on the line under Tonal. + let y = |text: &str| find(text).first().map(|n| n.bounds().unwrap_or_default().y0).unwrap_or_default(); + assert!(y("Outline") > y("Tonal") + 10.0, "Outline wraps to the next line"); +} diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index 33f3e503..b60bdc86 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -25,7 +25,7 @@ impl Default for Runtime { fn default() -> Self { Self { #[cfg(not(target_arch = "wasm32"))] - updater: Updater::new(&craft_update::suite::FIGCRAFT, env!("CARGO_PKG_VERSION")), + updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), #[cfg(not(target_arch = "wasm32"))] startup_check: false, #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b69840af..33c83164 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -5,6 +5,15 @@ //! below this crate knows about egui, so the frontend can be replaced. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// FigCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png")), + accent: Some([0x02, 0xda, 0x96]), + ..craft_update::suite::FIGCRAFT +}; + pub mod a11y; pub mod bidi; pub mod canvas; @@ -63,6 +72,8 @@ mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod component_set_narrow_tests; +#[cfg(test)] mod component_swap_default_tests; #[cfg(test)] mod component_swap_search_tests; @@ -121,6 +132,8 @@ mod layers_keys_tests; #[cfg(test)] mod layers_rename_tests; #[cfg(test)] +mod library_beacon_tests; +#[cfg(test)] mod live_chat_tests; #[cfg(test)] mod live_docs_tests; @@ -307,6 +320,7 @@ pub use control::{ControlReply, ControlRequest, RequestGate, RequestState}; mod app; mod clock; mod open; +mod selection_cache; mod services; mod state; diff --git a/crates/ui-egui/src/library_beacon_tests.rs b/crates/ui-egui/src/library_beacon_tests.rs new file mode 100644 index 00000000..e3b00162 --- /dev/null +++ b/crates/ui-egui/src/library_beacon_tests.rs @@ -0,0 +1,32 @@ +//! The Assets panel showing a file that names a team library on an unknown server doesn't +//! contact it (#1097): the library waits until the user clicks Check for updates. That a library +//! not allowed is never asked is the engine's `library_beacon_tests`; here, what allows it. + +use figcraft_doc::LibraryLink; +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; + +const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret"; + +#[test] +fn the_assets_panel_checks_an_unknown_library_server_only_when_asked() { + let mut s = Session::new(); + s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 1 }); + let mut u = Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.libraries = true; + for _ in 0..4 { + u.frame(vec![]); + u.advance_clock(60.0); + } + assert!(!u.app.session.library_allowed(LINK), "showing the panel allows nothing"); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Not checked yet")); + let at = text_center(&shapes, "Check for updates"); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + assert!(u.app.session.library_allowed(LINK), "allowed once the user checked"); +} diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index 4fe5566d..7592b257 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -50,6 +50,7 @@ const PAD: f32 = 16.0; const TO_SERVER: &str = "library.publishToServer"; const USAGE: &str = "library.usage"; const ADD: &str = "library.add"; +const CHECK: &str = "library.check"; /// Where an asset comes from: this file or an enabled library (its id). #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -288,8 +289,10 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { // Team libraries: in the background, at once for one not fetched yet. let sid = Id::unique("asset_server_libraries_checked"); let fetched: Option = ui.data(|d| d.get_temp(sid)); - let unread = paths.iter().any(|p| is_server_link(p) && app.session.libraries.cached(p).is_none()); - if paths.iter().any(|p| is_server_link(p)) && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) { + // Only the ones you added or checked: a file can't make the app contact a server (#1097). + let team: Vec<&String> = paths.iter().filter(|p| is_server_link(p) && app.session.library_allowed(p)).collect(); + let unread = team.iter().any(|p| app.session.libraries.cached(p).is_none()); + if !team.is_empty() && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) { app.session.refresh_server_libraries(); ui.data_mut(|d| d.insert_temp(sid, now)); } @@ -1054,7 +1057,8 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } Some(Ok(_)) => (tr("The file now holds another library").into(), t.danger), Some(Err(e)) => (e.to_string(), t.danger), - None if is_server_link(&l.path) => (tr("Fetching from the server…").into(), t.text_secondary), + None if is_server_link(&l.path) && app.session.library_allowed(&l.path) => (tr("Fetching from the server…").into(), t.text_secondary), + None if is_server_link(&l.path) => (tr("Not checked yet").into(), t.text_secondary), None => (tr("Not read yet").into(), t.text_secondary), }; let text_w = w - PAD - if swappable { 72.0 } else { 44.0 }; @@ -1092,6 +1096,17 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { }); } } + // A file names its team libraries' servers: they are contacted only once you say so (#1097). + let unchecked = doc.libraries.enabled.iter().any(|l| is_server_link(&l.path) && !app.session.library_allowed(&l.path)); + if unchecked { + hint(ui, tr("This file uses team libraries on servers you haven't used here. They aren't contacted until you check them.")); + ui.horizontal(|ui| { + ui.add_space(PAD); + if crate::widgets::button(ui, tr("Check for updates"), false, true).clicked() && !crate::menus::dev_mode_refuses(app, CHECK) { + app.run(CHECK, json!({})); + } + }); + } ui.add_space(4.0); let mut add = false; ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/component_panel.rs b/crates/ui-egui/src/panels/component_panel.rs index ddd7e3d3..048071e3 100644 --- a/crates/ui-egui/src/panels/component_panel.rs +++ b/crates/ui-egui/src/panels/component_panel.rs @@ -207,6 +207,23 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer }); } +/// The indent of a variant property's value lines (under its icon). +const INDENT: f32 = 22.0; +/// A variant value field. +const VALUE_W: f32 = 62.0; +/// The + and − buttons. +const BUTTON: f32 = 22.0; +/// A variant property's default dropdown. +const DEFAULT_W: f32 = 70.0; +/// A shared property's default (a text field, a dropdown; a checkbox takes less). +const SHARED_W: f32 = 84.0; + +/// A property row's name field: `max` wide, less when the panel is narrow so that the type icon, +/// `default_w` of default and the − button after it stay in view. +fn name_width(ui: &Ui, default_w: f32, max: f32) -> f32 { + (ui.available_width() - default_w - BUTTON - 2.0 * widgets::GUTTER).clamp(48.0, max) +} + /// Properties of a component or component set. fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let t = Tokens::get(ui.ctx()); @@ -257,15 +274,20 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { // Variant properties: name, values (the matrix), add value, delete. Dragged by their left // margin to reorder them (only among themselves: the shared ones keep the engine's order). let mut drag = ListDrag::in_order(ui, ("variant_props", owner)); + // As many value fields to a line as the panel fits (2 at its narrowest), with the + after them. + let inner = ui.available_width() - widgets::PAD - widgets::PAD_RIGHT; + let per_line = (((inner - INDENT - BUTTON - widgets::GUTTER) / (VALUE_W + widgets::GUTTER)).floor() as usize).max(1); for (i, (k, vals)) in variants.iter().enumerate() { let header = row_rect(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { + // The name gives way first, so the default and the delete button stay in view. + let name_w = name_width(ui, DEFAULT_W, 110.0); + if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, name_w, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default(); - let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text(tr("Default value (the top-left variant)")); + let resp = widgets::dropdown(ui, &def, DEFAULT_W, None).on_hover_text(tr("Default value (the top-left variant)")); menu_popup(&resp).show(|ui| { for v in vals { if menu_item(ui, v, "") { @@ -274,30 +296,31 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } } }); - if icons::button(ui, Icon::Minus, 22.0, false, tr("Delete property")).clicked() { + if icons::button(ui, Icon::Minus, BUTTON, false, tr("Delete property")).clicked() { app.run("component.deleteProperty", json!({"id": owner, "name": k})); } }); // Values: rename inline; "+" adds a variant with a new value. let mut vi = 0; while vi < vals.len() { - let chunk: Vec = vals.iter().skip(vi).take(3).cloned().collect(); + let chunk: Vec = vals.iter().skip(vi).take(per_line).cloned().collect(); let last = vi + chunk.len() >= vals.len(); row(ui, 30.0, |ui| { - ui.add_space(22.0); + ui.add_space(INDENT); for v in &chunk { - if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") + let field = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, VALUE_W, ""); + if let Some(nv) = field && nv != *v { app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv})); } } - if last && icons::button(ui, Icon::Plus, 22.0, false, tr("Add variant with a new value")).clicked() { + if last && icons::button(ui, Icon::Plus, BUTTON, false, tr("Add variant with a new value")).clicked() { let nv = (vals.len() + 1..).map(|n| format!("Value {n}")).find(|n| !vals.contains(n)).unwrap_or_default(); app.run("component.addVariant", json!({"id": owner, "property": k, "value": nv})); } }); - vi += 3; + vi += chunk.len().max(1); } drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || k.clone()); } @@ -314,12 +337,15 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, type_icon(d), COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { + let name_w = name_width(ui, SHARED_W, 96.0); + if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, name_w, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } match d { PropDef::Boolean { default } => { - if widgets::checkbox(ui, *default, if *default { tr("True") } else { tr("False") }) { + // As wide as the other defaults: a checkbox takes all the room, which the − needs. + let label = if *default { tr("True") } else { tr("False") }; + if ui.allocate_ui(vec2(SHARED_W, widgets::FIELD_H), |ui| widgets::checkbox(ui, *default, label)).inner { app.run("component.editProperty", json!({"id": owner, "name": k, "default": !default})); } } diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index b21e074c..117715d8 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -4,6 +4,8 @@ //! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*` //! commands. +use std::sync::Arc; + use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, LayoutMode, MainAlign, Node, NodeId, NodeKind, Sizing}; use serde_json::json; @@ -25,7 +27,7 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { /// Show selection sizing and layout controls; offer Add only when every selected container has /// no auto layout and the selection is editable outside an instance. -pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { +pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { let ids = sel.to_vec(); let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group))); @@ -35,8 +37,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let title = if has_al { tr("Auto layout") } else { tr("Layout") }; // An instance's auto layout is its main's (only padding, gap and alignment are its own); the // layers inside it override their size and resizing, not their place in the layout. - let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some()); - let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); + let facts = crate::right::in_instance(ui.ctx(), doc, sel); + let (in_instance, nested) = (facts.any, facts.nested); let btns: Vec<(Icon, &str)> = if in_instance { vec![] } else if layout_containers && no_al { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 2eeccea5..152aae70 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1,5 +1,7 @@ //! The right panel: Design, Prototype and Inspect tabs. +use std::sync::Arc; + use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::{BlendMode, Paint, PaintKind}; use figcraft_doc::{ @@ -274,7 +276,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::layout_panel::layout_section(app, ui, &doc, &sel); widgets::separator(ui); } - if vector_edit_none && (arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel)) { + if vector_edit_none && arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel) { widgets::separator(ui); } appearance(app, ui, &doc, &sel); @@ -500,21 +502,95 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { crate::component_panel::instance_section(app, ui, doc, sel); } -fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { +/// Whether a selected layer is an instance or in one, and whether one is inside an instance (not +/// the instance itself): the Design panel asks every frame, kept while the document and the +/// selection stay the same ([`crate::selection_cache`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct InInstance { + pub any: bool, + pub nested: bool, +} + +pub(crate) fn in_instance(ctx: &egui::Context, doc: &Arc, sel: &[NodeId]) -> InInstance { + use figcraft_engine::components::{inside_instance, owning_instance}; + crate::selection_cache::cached(ctx, "in instance", doc, sel, &[], || InInstance { + any: sel.iter().any(|i| owning_instance(doc, *i).is_some()), + nested: sel.iter().any(|i| inside_instance(doc, *i)), + }) +} + +fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { widgets::section_header(ui, tr("Position"), &[], false); // Layers inside an instance take their place and constraints from the main component. - let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); + let nested = in_instance(ui.ctx(), doc, sel).nested; ui.add_enabled_ui(!nested, |ui| position_fields(app, ui, doc, sel)) .response .on_disabled_hover_text(tr("Layers in an instance take their position from the main component")); ui.add_space(8.0); } +/// What the position fields show of the selection (`None`: mixed), kept like [`in_instance`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PositionValues { + pub can_align: bool, + pub x: Option, + pub y: Option, + pub rot: Option, + /// Every selected layer is in an auto layout frame, and whether they ignore its layout. + pub parent_al: bool, + pub absolute: Option, + /// Every selected layer is in a frame that places it by constraints, and those constraints. + pub in_frame: bool, + pub constraints: Option, +} + +/// What the X field shows of a layer (in its frame's space: groups have none of their own); a +/// relative edit of mixed values starts from it. +fn x_of(doc: &Document, n: &Node) -> f64 { + (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0 +} + +fn y_of(doc: &Document, n: &Node) -> f64 { + (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0 +} + +fn rot_of(n: &Node) -> f64 { + (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0 +} + +pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc, sel: &[NodeId]) -> PositionValues { + let parent = |n: &Node| n.parent.and_then(|p| doc.node(p)); + crate::selection_cache::cached(ctx, "position", doc, sel, &[], || PositionValues { + can_align: figcraft_engine::cmd::arrange::can_align(doc, sel), + x: common(doc, sel, |n| x_of(doc, n)), + y: common(doc, sel, |n| y_of(doc, n)), + rot: common(doc, sel, rot_of), + parent_al: sel.iter().all(|i| doc.node(*i).and_then(parent).is_some_and(|p| p.layout.is_some())), + absolute: common(doc, sel, |n| n.child_layout.absolute), + // Constraints: children of frames without auto layout, or ignoring it. + in_frame: sel.iter().all(|i| { + doc.node(*i).is_some_and(|n| parent(n).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute))) + }), + constraints: common(doc, sel, |n| n.constraints), + }) +} + /// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the /// absolute-position toggle and frame constraints. -fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - let ids = sel.to_vec(); +fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { + let pv = position_values(ui.ctx(), doc, sel); let col = widgets::col_width(ui.available_width()); + align_row(app, ui, sel, pv.can_align, col); + position_row(app, ui, doc, sel, &pv, col); + rotation_row(app, ui, sel, pv.rot, col); + if pv.in_frame { + constraints_row(app, ui, sel, pv.constraints, col); + } +} + +/// Alignment, and distribution and tidy up for three or more layers. +fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, col: f32) { + let ids = sel.to_vec(); widgets::sublabels(ui, tr("Alignment"), None); row(ui, 32.0, |ui| { let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"]; @@ -528,7 +604,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] (Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])), ]; let w = col / 3.0; - let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| { + let picked = ui.add_enabled_ui(can_align, |ui| { let h = widgets::segmented(ui, &items[..3], None, w); let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3); h.or(v) @@ -553,23 +629,20 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); } }); - // In the frame's space: groups have none of their own. - // What each field shows of a layer (a relative edit of mixed values starts from it). - let x_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0; - let y_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0; - let rot_of = |n: &Node| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0; - let (x, y, rot) = (common(doc, sel, x_of), common(doc, sel, y_of), common(doc, sel, rot_of)); - let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let absolute = common(doc, sel, |n| n.child_layout.absolute); +} + +/// X and Y, and the absolute-position toggle for children of auto layout frames. +fn position_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], pv: &PositionValues, col: f32) { + let ids = sel.to_vec(); widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0); - app.field_of(&ids, "x", e, x_of, |v| json!(v)); - let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0); - app.field_of(&ids, "y", e, y_of, |v| json!(v)); + let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), pv.x, "", col, 1.0); + app.field_of(&ids, "x", e, |n| x_of(doc, n), |v| json!(v)); + let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), pv.y, "", col, 1.0); + app.field_of(&ids, "y", e, |n| y_of(doc, n), |v| json!(v)); // Children of auto layout frames can ignore the layout (absolute position). - if parent_al { - let on = absolute.unwrap_or(false); + if pv.parent_al { + let on = pv.absolute.unwrap_or(false); if icons::button( ui, Icon::AbsolutePosition, @@ -583,6 +656,11 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] } } }); +} + +/// Rotation, with the rotate and flip buttons. +fn rotation_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], rot: Option, col: f32) { + let ids = sel.to_vec(); widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); @@ -596,50 +674,38 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids})); } }); - // Constraints (children of frames without auto layout, or ignoring it). - let in_frame = sel.iter().all(|i| { - doc.node(*i).is_some_and(|n| { - n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute)) - }) - }); - if in_frame { - let c = common(doc, sel, |n| n.constraints); - widgets::sublabels(ui, tr("Constraints"), None); - row(ui, 32.0, |ui| { - let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None); - menu_popup(&resp).show(|ui| { - for (l, v) in [ - (tr("Left"), "left"), - (tr("Right"), "right"), - (tr("Left & right"), "leftRight"), - (tr("Center"), "center"), - (tr("Scale"), "scale"), - ] { - if menu_item(ui, l, "") { - app.set_props(&ids, json!({"constraints": {"horizontal": v}})); - ui.close(); - } +} + +/// The horizontal and vertical constraints of children of frames without auto layout. +fn constraints_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], c: Option, col: f32) { + let ids = sel.to_vec(); + widgets::sublabels(ui, tr("Constraints"), None); + row(ui, 32.0, |ui| { + let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); + let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None); + menu_popup(&resp).show(|ui| { + for (l, v) in + [(tr("Left"), "left"), (tr("Right"), "right"), (tr("Left & right"), "leftRight"), (tr("Center"), "center"), (tr("Scale"), "scale")] + { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"constraints": {"horizontal": v}})); + ui.close(); } - }); - let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None); - menu_popup(&resp).show(|ui| { - for (l, val) in [ - (tr("Top"), "top"), - (tr("Bottom"), "bottom"), - (tr("Top & bottom"), "topBottom"), - (tr("Center"), "center"), - (tr("Scale"), "scale"), - ] { - if menu_item(ui, l, "") { - app.set_props(&ids, json!({"constraints": {"vertical": val}})); - ui.close(); - } - } - }); + } }); - } + let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); + let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None); + menu_popup(&resp).show(|ui| { + for (l, val) in + [(tr("Top"), "top"), (tr("Bottom"), "bottom"), (tr("Top & bottom"), "topBottom"), (tr("Center"), "center"), (tr("Scale"), "scale")] + { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"constraints": {"vertical": val}})); + ui.close(); + } + } + }); + }); } /// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running diff --git a/crates/ui-egui/src/selection_cache.rs b/crates/ui-egui/src/selection_cache.rs new file mode 100644 index 00000000..63ede6ab --- /dev/null +++ b/crates/ui-egui/src/selection_cache.rs @@ -0,0 +1,48 @@ +//! Values worked out from the document and the selection that several frames show unchanged +//! (#1571): a pointer move draws about three frames, and a large selection's outlines, slots and +//! mixed values cost milliseconds each time. A value is kept with the document snapshot it was +//! made from (compared by pointer: an edit is a new snapshot), the selection and whatever else it +//! depends on (`extra`: the view's mapping to the screen, a colour…), and made again when any +//! differs. + +use std::sync::{Arc, Weak}; + +use figcraft_doc::{Document, NodeId}; + +/// One kept value and what it was made from. The document is held weakly, so a kept value never +/// keeps an old snapshot alive; the weak reference keeps its address from being reused. +#[derive(Clone)] +struct Kept { + doc: Weak, + sel: Vec, + extra: Vec, + value: T, +} + +/// The value `name` for `doc`, `sel` and `extra`: the kept one when they are what it was made +/// from, else `make()`, kept for next time. +pub(crate) fn cached( + ctx: &egui::Context, + name: &'static str, + doc: &Arc, + sel: &[NodeId], + extra: &[u64], + make: impl FnOnce() -> T, +) -> T { + let id = egui::Id::unique(("selection_cache", name)); + if let Some(k) = ctx.data(|d| d.get_temp::>(id)) + && std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(doc)) + && k.sel == sel + && k.extra == extra + { + return k.value; + } + let value = make(); + ctx.data_mut(|d| d.insert_temp(id, Kept { doc: Arc::downgrade(doc), sel: sel.to_vec(), extra: extra.to_vec(), value: value.clone() })); + value +} + +/// A screen point as key material. +pub(crate) fn bits(p: egui::Pos2) -> [u64; 2] { + [u64::from(p.x.to_bits()), u64::from(p.y.to_bits())] +} diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 99af5f0f..6f296479 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -584,7 +584,7 @@ fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: & image_pending = true; let target = target.clone(); app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { - let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return }; + let Ok(bytes) = figcraft_engine::file_access::read_capped(&p, figcraft_doc::ImageData::MAX_BYTES) else { return }; let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; if let Some(mut paint) = current_paint(app, &target) { paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 2636fc32..4ddef04d 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -955,6 +955,12 @@ chooses to share on the network; the default is the invite link with a random to characters; presence numbers must be finite and selections are cut to 10,000 ids; signals are at most 1 KiB, chat 100 characters without control characters, 20 a second (bursts of 40, the rest dropped), and only known kinds are passed on. +- **A hostile host or server.** A client keeps track of at most 4,096 other people (their presence + waiting for the UI, the people a connection has seen, the session's list of who is here); past + that, someone new is left out with a logged warning, and someone leaving makes room. Events + waiting for the UI are capped at 10,000 (document batches excepted: dropping one would end the + session). The session core remembers at most 262,144 deleted layer ids for undo, forgetting the + lowest first; undoing the delete of a forgotten layer outside your own id space is refused. - **Malformed input.** Anything that isn't a binary message of the current wire version that decodes in full is answered with `Refused` and a close; the first message must be `Hello` (for this file) or, on a server, `Manage`. The invite parser and the host's reader are fuzzed diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 5513842b..b7ffa109 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); 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; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); 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); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); 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; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); 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); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -29,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. | | 2026-10-30 | minor | M8, #1074: `figcraft-server` keeps a file everyone left loaded for a minute (at most 16 files, 256 MiB together), so rejoining it reads nothing, and writes a file's open time at most hourly (and when it stops) instead of at every join and leave: join/leave cycles of an unchanged file do no disk I/O on the hub thread. Edits are saved as before. Numbers unchanged. | | 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry each thread's mentions as `comment.list` does. Numbers unchanged. | | 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`. | diff --git a/docs/components-parity.md b/docs/components-parity.md index 4cf94617..bbb3d455 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); 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); 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); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -78,6 +78,8 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. | +| 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0232e198..6c2d8501 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -70,7 +70,8 @@ unknown id is an error. request acts on that file without bringing its tab to the front; the tab in front comes back afterwards unless the command opened or switched to another file itself. A file that isn't open is an error, and so is a `document` naming a file behind the tab in front on any other method -(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows). +(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows). `doc.list` includes each file's +`path` only when the call names no `agent`: an AI agent's calls (MCP sets `agent`) get ids and names. ## Confining file access diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f98e8f44..524c8e39 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); 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); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); 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); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); 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); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); 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); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -186,6 +186,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | +| 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. | | 2026-10-30 | minor | M8, #1101: `.fig` and REST imports of files nested 256 levels deep (the cap) fit the browser's 1 MB stack: the REST layer walk no longer recurses. Numbers unchanged. | | 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. | | 2026-10-30 | minor | M8, #1079: a `.figcraft` file unpacks at most 6 GiB in all when it opens (2 GiB in the browser), every area together, beyond each area's own cap; what passes it is left out with a note (images stay in the file, #1313). Numbers unchanged. | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index f5d8196e..f3d88bd5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); 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); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); 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); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -51,6 +51,8 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. | +| 2026-10-30 | minor | M8, #1099: long batches (scripts, plugins, MCP `batch`) no longer hold a document copy per step: one undo snapshot per batch (a 1,000-step batch: +165 MiB to +10 MiB peak). Numbers unchanged. | | 2026-10-30 | minor | M8, #1082: canvas rendering of the deepest documents can't overflow a render thread: one depth cap bounds a render, pattern sources included, and render threads get 8 MiB stacks (a level takes about 6.5 KB). Numbers unchanged. | | 2026-10-30 | minor | M8, #1123: the browser can't catch a panic, so an internal error stopped the page and lost its unsaved changes. Its panic hook now keeps the files with unsaved changes in the page's local storage (as many as fit, about 5 MB) and says so on the page; reloading opens them again as unsaved files. Numbers unchanged. | | 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged | diff --git a/docs/images/art-app-home-dark.png b/docs/images/art-app-home-dark.png index bbfcfa88..d01008f7 100644 Binary files a/docs/images/art-app-home-dark.png and b/docs/images/art-app-home-dark.png differ diff --git a/docs/images/art-app-home.png b/docs/images/art-app-home.png index 19313773..dde4a5ec 100644 Binary files a/docs/images/art-app-home.png and b/docs/images/art-app-home.png differ diff --git a/docs/images/art-app-trail.png b/docs/images/art-app-trail.png index 62d1c17e..3bc4fdce 100644 Binary files a/docs/images/art-app-trail.png and b/docs/images/art-app-trail.png differ diff --git a/docs/images/art-dashboard.png b/docs/images/art-dashboard.png index 05fbc7c0..d449badb 100644 Binary files a/docs/images/art-dashboard.png and b/docs/images/art-dashboard.png differ diff --git a/docs/images/art-ds-colour.png b/docs/images/art-ds-colour.png index 6c2defe6..25b8a43f 100644 Binary files a/docs/images/art-ds-colour.png and b/docs/images/art-ds-colour.png differ diff --git a/docs/images/art-glass-weather.png b/docs/images/art-glass-weather.png index 1f14072b..803775ec 100644 Binary files a/docs/images/art-glass-weather.png and b/docs/images/art-glass-weather.png differ diff --git a/docs/images/art-vector-marks.png b/docs/images/art-vector-marks.png index 283c8166..e398bd7c 100644 Binary files a/docs/images/art-vector-marks.png and b/docs/images/art-vector-marks.png differ diff --git a/docs/images/shot-1-hero.png b/docs/images/shot-1-hero.png index 9a675b41..cece6bd7 100644 Binary files a/docs/images/shot-1-hero.png and b/docs/images/shot-1-hero.png differ diff --git a/docs/images/shot-2-components.png b/docs/images/shot-2-components.png index f928df29..5f73b537 100644 Binary files a/docs/images/shot-2-components.png and b/docs/images/shot-2-components.png differ diff --git a/docs/images/shot-3-prototype.png b/docs/images/shot-3-prototype.png index a4133d48..b3f304c7 100644 Binary files a/docs/images/shot-3-prototype.png and b/docs/images/shot-3-prototype.png differ diff --git a/docs/images/shot-4-devmode.png b/docs/images/shot-4-devmode.png index 42f9c3f0..7bd8db7e 100644 Binary files a/docs/images/shot-4-devmode.png and b/docs/images/shot-4-devmode.png differ diff --git a/docs/images/shot-5-vector.png b/docs/images/shot-5-vector.png index 3f06b177..37de9781 100644 Binary files a/docs/images/shot-5-vector.png and b/docs/images/shot-5-vector.png differ diff --git a/docs/images/shot-6-dashboard-light.png b/docs/images/shot-6-dashboard-light.png index 4479b539..f27bd959 100644 Binary files a/docs/images/shot-6-dashboard-light.png and b/docs/images/shot-6-dashboard-light.png differ diff --git a/docs/images/shot-7-collab.png b/docs/images/shot-7-collab.png index de91089b..100fb4b1 100644 Binary files a/docs/images/shot-7-collab.png and b/docs/images/shot-7-collab.png differ diff --git a/docs/images/shot-8-board.png b/docs/images/shot-8-board.png index ef0b9d49..b9a9a093 100644 Binary files a/docs/images/shot-8-board.png and b/docs/images/shot-8-board.png differ diff --git a/docs/images/shot-9-devmode-measure.png b/docs/images/shot-9-devmode-measure.png index 8dde99d7..6479b9fe 100644 Binary files a/docs/images/shot-9-devmode-measure.png and b/docs/images/shot-9-devmode-measure.png differ diff --git a/docs/mcp.md b/docs/mcp.md index fcd081ea..a25ae8fa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -350,7 +350,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | | `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). | -| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). | +| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). | @@ -892,7 +892,9 @@ most when it can't be reached). - `library.add {path: }` enables it (a view link is enough); then `library.place`, `library.applyStyle`, `library.bindVariable`… work as for library files. `library.list` and `library.updates` ask the server for a newer version first (`"server": true` in the listing); - `library.acceptUpdates` takes it, overrides kept. + `library.acceptUpdates` takes it, overrides kept. The app's own background checks ask only about + team libraries added (`library.add`) or checked (`library.check {library?}`) on this computer, + so opening a file that names another one contacts nothing (#1097). - `library.usage {library?}` → `{id, name, instances, files: [{name, updated, instances}], components: [{id, name, instances, files}]}`: what the files using it reported (each fetch reports the asking document's name and its instances per component). `library` is an enabled @@ -1333,7 +1335,7 @@ take an optional `document`: the file's `id` (in `run_command {command: "doc.lis of the `document` outline `open_file` and `inspect_document` return) or the path of an open file. The call acts on that file and the tab in front stays in front, unless the command itself opens or switches to another file (`file.open`, `file.new`, `doc.switch`), as it would without `document`. A -file that isn't open is an error. `screenshot {window}` captures what the window shows, so it +file that isn't open is an error. `doc.list` gives an agent each open file's `id`, name and state, not`nwhere it is on disk (#1117). `screenshot {window}` captures what the window shows, so it needs the file in front. Over the control channel, `engine.execute`, `document.inspect`, `node.get` and `ui.render` take the same `document`; other methods refuse one naming a file behind the tab in front. Headless sessions take it too. diff --git a/docs/plugins.md b/docs/plugins.md index 4eb81eed..2c10377e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -262,8 +262,8 @@ it can handle): back), `codeConnect.import` with a `path` and `variable.import` with a file path instead of JSON, `window.*` (new windows open files), `view.theme*`; - live sessions and online files: `collab.*`; -- other documents and the version history: `doc.*` and `version.*` (except `doc.list` and - `version.list`); +- other documents and the version history: `doc.*` and `version.*` (except `doc.list`, which gives a + plugin the open files' names but not their paths, and `version.list`); - the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is already one undo step), `plugin.*` / `plugins.*` (no plugin runs another) and `widget.*`; - writing layer data whose key starts with `figcraft.widget` (a widget's own data: no plugin can diff --git a/docs/roadmap.md b/docs/roadmap.md index 17225ee9..0083dd62 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -57,7 +57,7 @@ blockers. | M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% (`.fig` ZIP image reads bounded in total, #1076) (explicit variables files capped at 256 MiB 2026-10-30, part of #1114) | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | -| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | +| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide; the server key compared by its hash (#1130) | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Arabic draft: 2,508 entries, native review and panel mirroring pending | native reviews (including the Arabic draft), Hindi, Vietnamese, Arabic panel mirroring, engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | | M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 | | M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 | @@ -76,6 +76,15 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M8, #1076: `.fig` ZIP image reads share a total budget, 4 GiB on 64-bit targets and 1 GiB on 32-bit targets, keeping the 256 MiB per-image cap. Members that do not fit are skipped before allocation with an import warning; failed reads consume their bytes, repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | +| 2026-10-30 | minor | M8, #1114 (the rest): documents, placed images and CLI scripts read by path are regular files within caps (16 GiB, 256 MiB, 1 GiB); folders, devices and pipes are refused before anything is read. Tests: `path_read_caps_tests` | +| 2026-10-30 | minor | M8: a component set's property rows fit the 240 px right panel (values wrap; + and − stay in view). Tests: `component_set_narrow_tests` | +| 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | +| 2026-10-30 | minor | M8, #1097: team libraries are checked in the background only when you added or checked them here (`library.check`); opening a file that names another server contacts nothing. Tests: `library_beacon_tests` | +| 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. | +| 2026-10-30 | minor | M8, #1117: `doc.list` leaves out file paths for AI agents and plugins (ids and names stay). Tests: `doc_list_paths_tests` | +| 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. | +| 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). | +| 2026-10-30 | minor | M8, #1099: a batch's edits add to the one undo step it pushed, so it keeps one snapshot instead of one per step (1,000 steps: +165 MiB to +10 MiB peak); still one undo step, rolled back as a unit. Tests: `batch_undo_snapshot_tests` | | 2026-10-30 | minor | M8, #1101: the REST import's layer walk runs on a work list (it took about 4 KB of stack a level, a megabyte at the 256-level cap); `.fig` and REST files 256 levels deep import on a 1 MB stack, the browser's. Tests: `deep_import_tests` | | 2026-10-30 | minor | M8, #1082 (the rest): SVG and PDF pattern sources and the renders exports embed go on from the layer's depth: one cap for a whole export. Tests: `pattern_depth_tests` (SVG, PDF), `render_depth_tests` | | 2026-10-30 | minor | M8, #1082: pattern sources go on from the depth of the layer they fill (one cap for the whole render); render, save and band threads get 8 MiB stacks (a level takes about 6.5 KB). Tests: `render_depth_tests` | diff --git a/docs/server.md b/docs/server.md index f763b505..0cc77cf7 100644 --- a/docs/server.md +++ b/docs/server.md @@ -209,6 +209,12 @@ which other files use without anyone copying the library file around. **Library updates available**; accepting keeps instance overrides. While the server can't be reached, the last version fetched stays in use. The file keeps the link, token included: sharing that file shares read access to the library. +- **Only libraries you chose are checked by themselves.** The app asks only about team libraries + you added or checked on this computer (kept as hashes of their links in the preferences). A file + someone sent you that names another one contacts nothing when you open it: **Assets ▸ Libraries** + says it isn't checked yet, and **Check for updates** (`library.check`) asks its server and + checks it by itself from then on (#1097). Placing one of its components or applying one of its + styles asks the server too, since you did that yourself. - **Usage.** Each time a file's app asks for the library it reports which of its components the file uses and how often. **Show usage** in the library file (`library.usage`) lists the files and the instances of each component. Files are told apart by their names; a file's report changes @@ -291,7 +297,7 @@ are errors): | `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server). The app opens files of up to 1023 MiB; above 64 with `open_publish` the server warns | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | -| `max_people` | 256 | Most people in one file at once | +| `max_people` | 256 | Most people in one file at once (a value above 4096 is lowered to it, with a warning at startup: clients keep track of no more) | | `compact_after` | 1000 | Compact a file's log into a new snapshot after this many entries… | | `compact_mb` | 16 | …or once its log is this large (MiB) | | `max_log_mb` | 64 | Most MiB of recent batches a file in use keeps in memory (and in its snapshot) for apps that reconnect; an app further behind catches up with the whole file. Open to the public, at most a quarter of `max_file_mb` too | @@ -467,7 +473,7 @@ What someone can do, by what they hold (the server is reached through TLS at the | Who | Can | Cannot | |---|---|---| -| Anyone who reaches the server, with nothing | Ask about its hosting (open, operator, terms, limits); on an open server publish files within the limits per address and for everyone; list and download the plugin directory; ask `/healthz`; try join codes, within the limits on wrong ones | Join, read or list any file; see links, reports or blocked addresses; use or learn the server key or anyone's token (none are ever sent back, and all are compared in constant time against hashes); publish plugins; spoof their address through `X-Forwarded-For` (believed only from `trusted_proxy`); get past a block | +| Anyone who reaches the server, with nothing | Ask about its hosting (open, operator, terms, limits); on an open server publish files within the limits per address and for everyone; list and download the plugin directory; ask `/healthz`; try join codes, within the limits on wrong ones | Join, read or list any file; see links, reports or blocked addresses; use or learn the server key or anyone's token (none are ever sent back; a presented secret is hashed with SHA-256 and the two digests, always the same length, are compared without stopping at the first difference; hashing and the rest of the request still take time that depends on what was sent); publish plugins; spoof their address through `X-Forwarded-For` (believed only from `trusted_proxy`); get past a block | | Someone with a **join code** | Exactly what its link gives (a view code: the view link's row; a comment code: the comment link's; an edit code: the edit link's), until the code or its link is gone | Get a code for any other link; learn anything from a wrong code but that it is wrong | | Someone with a file's **view link** | See that file and everyone's changes and presence in it; read its team library and usage; report it | Change it (the server refuses a viewer's edits); reach any other file; manage links | | Someone with a file's **comment link** | What a view link gives, and add, reply to, react to, resolve, move and remove that file's comment threads (anyone's: comments are signed with the name in Preferences, there are no accounts) | Change anything else in it (the server refuses a batch that touches more than comment threads); reach any other file; manage links | @@ -478,7 +484,10 @@ What someone can do, by what they hold (the server is reached through TLS at the Limits that hold against everyone: message sizes by what a connection is for, file sizes as they grow, connections in all and per address and block, connection, message, publish and download rates per address and block, files and MiB per address and for everyone, the bytes queued for slow -clients and waiting for the server, and idle expiry ([Resource limits](#resource-limits)). Revoking +clients and waiting for the server, and idle expiry ([Resource limits](#resource-limits)). A +library or plugin larger than an address's download burst is still served, only from a full +budget that it then owes, so a large object comes more slowly but never lets an address past its +rate: over any stretch of time it gets at most the largest object plus the rate times that time. Revoking a link or removing a file cuts every connection that used it, library connections included. Not defended against: someone with many unrelated addresses (each gets its own limits; the global caps still hold, so they can fill the server up to them until you block them or the files expire), a @@ -508,7 +517,7 @@ are larger. | Bytes being read | Messages not yet whole: the largest message and 1 MiB per block, 192 MiB for everyone; past everyone's budget the readers holding the most are cut off until it fits (their bytes count as free at once), and a reader is refused only when it is the biggest | | Bytes waiting for the server | 128 MiB of read messages not handled yet: past it a connection is dropped as busy | | Connections | `max_connections` (512) in all (*always*); `max_connections_per_ip` (32) per address, ×2 per IPv6 /56 (at most a sixteenth of all), ×4 per /48 (at most an eighth of all), joins included, so no block can take every connection; one-shot requests (asking about hosting, libraries, browsing plugins) can't take the last eighth, which stays for people joining files, and are dropped after 20 idle seconds | -| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); requests that change files (join codes, links, renames, removals, blocks, publishing with the key), sixty a minute, with the server key or an owner token too ("too many changes to this server's files from your address…"; listing and looking aren't counted) (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together | +| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); requests that change files (join codes, links, renames, removals, blocks, publishing with the key), sixty a minute, with the server key or an owner token too ("too many changes to this server's files from your address…"; listing and looking aren't counted) (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*; a larger one is served once the address's budget is full, which it then pays back at that rate before its next download), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together | | Team libraries | Only files published with the key hold one on a public server; an archive is at most `max_file_mb` and everything it unpacks to counts against the same cap (*always*), so a zip bomb is refused before it expands | | Usage reports | Kept in memory, written at most once a minute per library and when the server stops; at most 1,000 components per file and 200 files per library (*always*) | | Disk writes | A batch is confirmed only once it is on disk; when its log can't be written, nobody hears it was accepted: the file closes, everyone in it reconnects and their apps send again what wasn't confirmed. Below 1 GiB (or two of the largest files) free, changes and new files are refused with "the server's disk is nearly full" instead (*always*, where the free space can be told: Unix). The server keeps the last 200 problems for the operator |