diff --git a/Cargo.lock b/Cargo.lock index bc2dcbc15..9e50554ed 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=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", diff --git a/Cargo.toml b/Cargo.toml index 0dda19673..e86780cef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,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 with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", 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 75328de15..f6e3d7692 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 97aaf1c6d..6f584593e 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 (the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); 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 (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,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 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-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index 05eb51222..386c397fb 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 01ef3cbda..e2c48e050 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 000000000..61f367305 --- /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 8226aa3f7..be28d22d6 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 c488d19fc..575cf79cb 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 b63f09932..be69b030c 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/library.rs b/apps/figcraft-server/src/store/library.rs index 55ca50add..2e59e8215 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/src/main.rs b/apps/figcraft/src/main.rs index 22c847560..a9675da22 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/crates/collab-net/src/client_cap_tests.rs b/crates/collab-net/src/client_cap_tests.rs new file mode 100644 index 000000000..920d53483 --- /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 1ec00e67c..860a80cfe 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 43b0b7b88..29c477f73 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 656c41b07..523fbda08 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 43e560011..9c35b5d1c 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 000000000..148024383 --- /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 51be86b4e..fd0b9bcbf 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 b3f924154..8cc37167c 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 38d87c2c9..9977b2309 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 9dc85e6ee..6ec7e8245 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/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index b9dbce478..5950b681b 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/batch_undo_snapshot_tests.rs b/crates/engine/src/batch_undo_snapshot_tests.rs new file mode 100644 index 000000000..81f415c7b --- /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/file.rs b/crates/engine/src/cmd/file.rs index 1c9402e9e..c99914f4b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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/library.rs b/crates/engine/src/cmd/library.rs index 0c7edbb2b..f4f70ce44 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 000000000..0c62a18dd --- /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 a9cad9c98..ba412ba69 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 4dbfa7e60..cd9d35ec6 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 { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf40c15f7..e0ef1ff72 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; diff --git a/crates/engine/src/library_beacon_tests.rs b/crates/engine/src/library_beacon_tests.rs new file mode 100644 index 000000000..187ea5264 --- /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 000000000..2035aa8c4 --- /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/prefs.rs b/crates/engine/src/prefs.rs index 3c241ef6b..709482218 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 6b0b265d8..050244813 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 285fc8a69..a10b3f4b7 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 4de47b11f..ec3c094e1 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 c50f0acb9..757decbc5 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 31ada26ac..93455c0d8 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/render/src/bounds_memo_tests.rs b/crates/render/src/bounds_memo_tests.rs index 4d0b305b4..44d7dc420 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 4e1d864ca..c2ab8d095 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 9a91bebce..24b66a68e 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 0b41c0f5b..4a988de6a 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 f7e0cf4dd..6e0e9fc73 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 06a9fc4bf..adfb45b7e 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 33c7e3960..aa9d040b1 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 e76aa0e13..7529d93b8 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 f95ecb03e..95743f10a 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 1a9af63e9..851583efc 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 932c1d726..894f4eeac 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 b3bd1bc9e..56d09e3a2 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 299d322d5..84eccc637 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 298722c4c..6cc8ad2f7 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 35f8825c5..4014462b1 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 5524afc68..681eb524b 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 4f9b27329..89a993346 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 7b60c4c92..2081b1cd5 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 64d3dc131..4033ecc8e 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 9cb885913..01c2813a5 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 7832111bf..549b7ff82 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 f17434ff6..4af404982 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 ef525a8b7..6f84aa376 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 1f3ec5e96..e729d8cc0 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 d5934657d..ae545b2f7 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 0fe1c315f..ae39c7e0e 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 a0dc2945a..154a4de01 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 5e932324a..e34dc341b 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 000000000..ca20e5447 --- /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/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index 33f3e5036..b60bdc863 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 b69840afe..6061b91af 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; @@ -121,6 +130,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 +318,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 000000000..e3b001627 --- /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 4fe5566df..7592b2574 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/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index b21e074c6..117715d87 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 2eeccea58..152aae707 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 000000000..63ede6abb --- /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/docs/collab-protocol.md b/docs/collab-protocol.md index 2636fc322..4ddef04df 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/components-parity.md b/docs/components-parity.md index 4cf946178..41d0b23ed 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 (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,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 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 0232e198c..6c2d85018 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/hardware-parity.md b/docs/hardware-parity.md index f5d8196e5..f3d88bd52 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 bbfcfa88e..d01008f76 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 193137738..dde4a5ec5 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 62d1c17ea..3bc4fdce2 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 05fbc7c0f..d449badb3 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 6c2defe6c..25b8a43fb 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 1f14072b1..803775ecf 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 283c8166a..e398bd7cd 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 9a675b41f..cece6bd7f 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 f928df293..5f73b537e 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 a4133d483..b3f304c75 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 42f9c3f0f..7bd8db7ee 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 3f06b1770..37de9781e 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 4479b5398..f27bd959a 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 de91089bc..100fb4b1a 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 ef0b9d490..b9a9a0937 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 8dde99d7a..6479b9fe7 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 fcd081ea7..a25ae8fad 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 4eb81eedf..2c10377e6 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 a08dd2e81..3b0b395a7 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 (the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); 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 (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 @@ -76,6 +76,12 @@ blockers. | Date | Change | Summary | |---|---|---| | 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 7ca68459f..0cc77cf7e 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 | @@ -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 |