diff --git a/ROADMAP.md b/ROADMAP.md index c209724c5..f54bf2a8d 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 (team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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 (a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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 @@ -59,7 +59,7 @@ native speakers' reviews of the nine language drafts need people. | Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 60% (`.fig` read best effort with editable vectors and a total ZIP image byte budget; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | +| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links, team libraries and a team list of the files shared now; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, #1125) | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`#join=…`). A browser build served beside its server names it with ``, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged | | 2026-10-30 | minor | M3, gap 10: team library analytics count detached instances. Detaching an instance of a library component counts it in the file (`libraries.detached`, saved with it, at most 1,000 components); the background library check reports the counts to the library's server after its fetch (`reportDetached`), which keeps them per file beside the file's usage report (same caps: 1,000 components, 200 files, names cleaned). **Show usage** and `library.usage` list detaches per component and per file (`detached`, `detachesKnown`); a component only detached shows just that. New request and reply variants appended to the library messages keep every older message's bytes, so older apps and servers are unaffected: an older server refuses the reports (ignored) and Show usage says it doesn't count detaches. Checked against a local figcraft-server with the app driven over the control channel. Numbers unchanged. Tests: `library_detach_tests` (server, end to end), `codec_tests::older_library_messages_keep_their_encoding`, `assets::library::tests::add_place_and_update_keep_overrides`, `cmd::library::detach_report_tests`. | | 2026-10-30 | patch | M5, gap 15: skipping the Open link notice with `proto.linkNotice` (the Actions palette, the control channel, MCP) changed the preference in memory only, so it was lost if the app crashed before quitting. A command can now mark the preferences to be kept at once (`Session::prefs_unsaved`), which the app saves at the end of the frame like installed plugins and live-session tokens; `proto.linkNotice` does, and the notice's own "Don't show this again" goes the same way. Numbers unchanged. Test: `present_links_tests::skipping_the_notice_by_command_is_saved_at_once`. | | 2026-10-30 | minor | M12, #1596: the bar over a selected connector on a board had the line type and one arrow button. It now has, beside the board colours and the line type, a colour wheel opening the colour picker for any colour (when only connectors are selected), menus for the start and end points (every line end the Design panel offers, each shown), and a line style menu (solid or dashed, weights 1, 2, 4 and 6), each control naming itself on hover. The arrow toggle gave way to the end point menu. UI only: `board.setStyle` caps and the stroke's dashes and weight, one undo step each. Tests: `board_line_tests.rs` (headless). | diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index 02bdfbed9..32fa7cf9c 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -44,7 +44,8 @@ Options (also keys of the --config TOML file): --public-url URL how links name the server behind a reverse proxy (wss://host/path/) --config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links, max_connections, max_people, compact_after, compact_mb, max_log_mb, - max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish, + max_plugins, max_plugin_mb, max_plugins_mb, team_files, web_url; + for a public server: open_publish, operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb, max_files_per_ip, max_mb_per_ip, max_connections_per_ip, connects_per_minute, publishes_per_hour, messages_per_second diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index e2c48e050..fd56e02df 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -3,6 +3,7 @@ use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; +use figcraft_collab::message::Access; use figcraft_collab_net::{Endpoint, NetLimits}; use serde::Deserialize; @@ -72,6 +73,13 @@ pub struct Config { pub connects_per_minute: u32, pub publishes_per_hour: u32, pub messages_per_second: u32, + /// The team list: everyone who reaches the server sees the files shared from it now (a link of + /// theirs has a join code) and opens them with this access, `"edit"`, `"comment"` or `"view"`. + /// Empty (the default): no team list. Only for a server just your team reaches. + pub team_files: String, + /// Where the browser build is served for this server (`https://figcraft.example.com/app/`): the + /// team list's browser links point there. Empty: none. + pub web_url: String, } impl Default for Config { @@ -103,6 +111,8 @@ impl Default for Config { connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200, + team_files: String::new(), + web_url: String::new(), } } } @@ -185,6 +195,26 @@ impl Config { self.max_people.min(figcraft_collab::message::MAX_PEERS) } + /// What the team list lets people do with its files (`None`: there is no team list). + pub fn team(&self) -> Result, String> { + let t = self.team_files.trim(); + if t.is_empty() { + return Ok(None); + } + Access::parse(t).map(Some).ok_or_else(|| "`team_files` is \"edit\", \"comment\" or \"view\" (empty: no team list)".to_string()) + } + + /// The browser build's address for the team list's links, when set: an `https://` (or + /// `http://`) address without a query or fragment. + pub fn web(&self) -> Result { + let w = self.web_url.trim(); + let web = w.starts_with("https://") || w.starts_with("http://"); + if w.is_empty() || (web && w.len() <= 500 && !w.contains(['?', '#']) && !w.contains(char::is_whitespace) && !w.contains(char::is_control)) { + return Ok(w.to_string()); + } + Err("`web_url` must be a web address without a query (https://figcraft.example.com/app/), at most 500 characters".into()) + } + /// 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) }; @@ -196,6 +226,8 @@ impl Config { self.addr()?; self.public()?; self.proxy()?; + self.team()?; + self.web()?; if !self.idle_days.is_finite() || !(0.0..=3650.0).contains(&self.idle_days) { return Err("`idle_days` must be from 0 to 3650".into()); } @@ -262,6 +294,9 @@ impl Config { max_total_bytes: mib(self.max_total_mb), max_files_per_ip: self.max_files_per_ip, max_bytes_per_ip: mib(self.max_mb_per_ip), + // `check` refused anything else before the store is made. + team: self.team().ok().flatten(), + web: self.web().unwrap_or_default(), abuse: AbuseConfig { open: self.open_publish, connects_per_minute: self.connects_per_minute, diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index c681dd4ab..8e61429b7 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -117,4 +117,6 @@ mod server_tests; #[cfg(test)] mod store_tests; #[cfg(test)] +mod team_tests; +#[cfg(test)] mod test_util; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ff0060758..3e7df46a9 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -7,7 +7,8 @@ //! /server.key the server key: managing files (publish, links, removal) //! /files//meta.json name, created, updated, sequence number //! /files//links.json share links: id, access, label, created, SHA-256 of the token, -//! and a join code's hash and wrapped token (see `codes`) +//! a join code's hash and wrapped token (see `codes`), and a team +//! link's token (see `team`) //! /files//snapshot.bin the session state at the last compaction //! /files//log.bin what changed since (see `disk`) //! /files//library-.bin its team library, when one was published (a `.figcraft` @@ -57,6 +58,7 @@ mod files; mod journal; mod library; mod links; +pub(crate) mod team; pub use library::library_name; @@ -145,6 +147,10 @@ pub struct HostingConfig { pub max_files_per_ip: usize, pub max_bytes_per_ip: u64, pub abuse: AbuseConfig, + /// The team list (`team_files`): what its links let people do; `None`: no team list. + pub team: Option, + /// Where the browser build is served (`web_url`; empty: not said). + pub web: String, } impl Default for HostingConfig { @@ -159,6 +165,8 @@ impl Default for HostingConfig { max_files_per_ip: usize::MAX, max_bytes_per_ip: u64::MAX, abuse: AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 }, + team: None, + web: String::new(), } } } @@ -199,7 +207,8 @@ struct LibraryMeta { version: u64, } -/// A share link (`links.json`): the token itself is never stored. +/// A share link (`links.json`): the token itself is never stored, except a team link's (the server +/// hands it to whoever asks the team list for it). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] struct Link { id: String, @@ -211,6 +220,20 @@ struct Link { /// Its join code, when one was handed out (never the code itself). #[serde(default, skip_serializing_if = "Option::is_none")] code: Option, + /// A team link's token: the server made the link for its team list. + #[serde(default, skip_serializing_if = "Option::is_none")] + team: Option, +} + +/// A team link's token, kept so the team list can hand it out; never shown in debug output. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +struct TeamToken(String); + +impl std::fmt::Debug for TeamToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("TeamToken(..)") + } } impl Link { @@ -425,6 +448,7 @@ impl Store { pub fn open(root: &Path, cfg: StoreConfig) -> Result { let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?; store.sweep(); + store.drop_stale_team_links(); Ok(store) } @@ -630,5 +654,5 @@ fn write_json(path: &Path, value: &T) -> Result<(), Strin /// A new link and its token. fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> { let token = token::secret()?; - Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) + Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None, team: None }, token)) } diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index 3ef679d0e..0f088f707 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -1,4 +1,5 @@ use super::journal::flush_log; +use super::team::team_admits; use super::{ Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, IpAddr, LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret, @@ -9,14 +10,37 @@ use super::{ fn changes_files(req: &Admin) -> bool { !matches!( req, - Admin::Files | Admin::Links { .. } | Admin::Listing | Admin::Reports | Admin::Blocked | Admin::Owned { .. } | Admin::RemovePeer { .. } + Admin::Files + | Admin::Links { .. } + | Admin::Listing + | Admin::Reports + | Admin::Blocked + | Admin::Owned { .. } + | Admin::RemovePeer { .. } + | Admin::Team ) } +impl Store { + /// Revoke link `link` of `file`, and the file's team links once it isn't shared any more: + /// whoever joined with any of them is disconnected. + fn revoke_all(&mut self, file: String, link: String) -> Handled { + match self.revoke(&file, &link) { + Ok(()) => { + let mut revoked = vec![(file.clone(), link)]; + revoked.extend(self.team_unshared(&file).into_iter().map(|l| (file.clone(), l))); + Handled { reply: AdminReply::Done, revoked, removed: None, blocked: None } + } + Err(reason) => AdminReply::Failed { reason }.into(), + } + } +} + impl Backend for Store { fn admit(&mut self, file: &str, token: &str) -> Result { + let team = self.cfg.hosting.team; let entry = self.entry(file)?; - let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); + let link = Self::link_of(entry, token).filter(|&i| team_admits(team, entry, i)).and_then(|i| entry.links.get(i)); link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) } @@ -166,6 +190,8 @@ impl Backend for Store { }; } Admin::Owned { files } => return self.owned(files).into(), + // Anyone who reaches the server, when its operator turned the team list on. + Admin::Team => return self.team(open).into(), Admin::Links { file } | Admin::CreateLink { file, .. } | Admin::Revoke { file, .. } @@ -198,21 +224,19 @@ impl Backend for Store { Admin::Files => AdminReply::Files(self.list(open)).into(), Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), - Admin::Revoke { file, link } => match self.revoke(&file, &link) { - Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, - Err(e) => failed(e), - }, + Admin::Revoke { file, link } => self.revoke_all(file, link), Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), Admin::Listing => AdminReply::Listing(self.listing(open)).into(), Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), Admin::Block { ip } => match self.block(&ip, true) { - Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, + Ok(ip) => Handled { reply: AdminReply::Done, revoked: Vec::new(), removed: None, blocked: Some(ip) }, Err(e) => failed(e), }, Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), + Admin::Team => self.team(open).into(), // The hub removes people itself (it knows who is connected); it never asks the store. Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), Admin::Remove { file } => { @@ -223,7 +247,7 @@ impl Backend for Store { Ok(()) => AdminReply::Done, Err(reason) => AdminReply::Failed { reason }, }; - Handled { reply, revoked: None, removed: Some(file), blocked: None } + Handled { reply, revoked: Vec::new(), removed: Some(file), blocked: None } } } } @@ -266,6 +290,10 @@ impl Backend for Store { self.resolve_code(ip, code) } + fn resolve_team(&mut self, _ip: IpAddr, file: &str) -> Result { + self.team_link(file).map(|l| l.token) + } + fn closing(&self) -> Closing { Closing::Restarting } diff --git a/apps/figcraft-server/src/store/team.rs b/apps/figcraft-server/src/store/team.rs new file mode 100644 index 000000000..24524e3a6 --- /dev/null +++ b/apps/figcraft-server/src/store/team.rs @@ -0,0 +1,119 @@ +//! The team list (`team_files`): everyone who reaches the server sees the files shared from it now +//! and opens them with a team link, one per file, that the server makes the first time someone +//! asks for it and keeps (its token readable, unlike other links') while the file stays shared. A +//! file is shared while a link of its own (not a team link) has a join code: Share ▸ Start sharing +//! makes such links, Stop sharing revokes them, and with the last of them the team links go too +//! (whoever joined with one is disconnected, as with the links themselves). + +use figcraft_collab::message::{MAX_TEAM, TeamFile}; + +use super::{Access, AdminReply, Entry, LINKS, NewLink, OpenFile, Store, TeamToken, new_link, now, write_json}; + +/// What a server without a team list answers. +pub const NO_TEAM: &str = "this server keeps no team list (its operator can turn one on with team_files)"; + +/// What asking for the link of a file that isn't shared now answers. +pub const NOT_SHARED: &str = "this file isn't shared now (its owner stopped sharing it, or removed it)"; + +/// The label of a team link (as `figcraft-server links list` shows it). +fn label(access: Access) -> String { + format!("Team ({})", access.id()) +} + +/// Whether `entry` is shared now: a link of its own has a join code. +fn shared(entry: &Entry) -> bool { + entry.links.iter().any(|l| l.team.is_none() && l.code.is_some()) +} + +impl Store { + /// The files shared now, most recently changed first (at most [`MAX_TEAM`]). + pub(super) fn team(&self, open: &dyn Fn(&str) -> Option) -> AdminReply { + let Some(access) = self.cfg.hosting.team else { return AdminReply::Failed { reason: NO_TEAM.into() } }; + let mut files: Vec = + self.files.iter().filter(|(_, e)| shared(e)).map(|(id, e)| TeamFile { file: self.info(id, e, open(id)), access }).collect(); + files.sort_by(|a, b| b.file.updated.cmp(&a.file.updated).then_with(|| a.file.name.cmp(&b.file.name))); + files.truncate(MAX_TEAM); + AdminReply::Team { web: self.cfg.hosting.web.clone(), files } + } + + /// The link the team joins `file` with: made now when it has none with the team list's access. + /// It replaces a team link made with another access before the operator changed the setting + /// (that took a restart, so nobody is in the file with it). + pub(super) fn team_link(&mut self, file: &str) -> Result { + let access = self.cfg.hosting.team.ok_or(NO_TEAM)?; + let max = self.cfg.max_links; + let path = self.dir(file).join(LINKS); + let entry = self.files.get_mut(file).filter(|e| shared(e)).ok_or(NOT_SHARED)?; + let made = entry.links.iter().find_map(|l| match &l.team { + Some(TeamToken(token)) if l.access == access => Some(NewLink { link: l.info(file), token: token.clone() }), + _ => None, + }); + if let Some(made) = made { + return Ok(made); + } + // Made before the list changes: a failure leaves the links as they were. + let (mut link, token) = new_link(access, &label(access), now())?; + let before = entry.links.clone(); + entry.links.retain(|l| l.team.is_none()); + if entry.links.len() >= max { + entry.links = before; + return Err(format!("a file has at most {max} links: revoke one first")); + } + link.team = Some(TeamToken(token.clone())); + entry.links.push(link.clone()); + if let Err(e) = write_json(&path, &entry.links) { + entry.links = before; + return Err(format!("couldn't keep the team link: {e}")); + } + log::info!("event=team_link file={file} link={}", link.id); + Ok(NewLink { link: link.info(file), token }) + } + + /// At startup: the team links the list doesn't hand out now (it is off, or gives another + /// access) are removed for good, so setting `team_files` back doesn't bring them back. Nobody + /// keeps a team link's token for later (a team list link names none), so nothing is lost: the + /// next join makes a new one. One that can't be removed stays refused at the door, and the + /// operator is told. + pub(super) fn drop_stale_team_links(&mut self) { + let team = self.cfg.hosting.team; + let stale = |l: &super::Link| l.team.is_some() && team != Some(l.access); + let files: Vec = self.files.iter().filter(|(_, e)| e.links.iter().any(stale)).map(|(id, _)| id.clone()).collect(); + for file in files { + let path = self.dir(&file).join(LINKS); + let Some(entry) = self.files.get_mut(&file) else { continue }; + entry.links.retain(|l| !stale(l)); + if let Err(e) = write_json(&path, &entry.links) { + self.note(format!( + "file {file}: its old team links couldn't be removed ({e}); they are refused, but would work again if team_files were set back to their access" + )); + } + } + } + + /// `file` stopped being shared (a link was revoked): revoke its team links too. The ids + /// revoked (whoever joined with them is disconnected); none while it is still shared. + pub(super) fn team_unshared(&mut self, file: &str) -> Vec { + let path = self.dir(file).join(LINKS); + let Some(entry) = self.files.get_mut(file).filter(|e| !shared(e)) else { return Vec::new() }; + let gone: Vec = entry.links.iter().filter(|l| l.team.is_some()).map(|l| l.id.clone()).collect(); + if gone.is_empty() { + return gone; + } + let before = entry.links.clone(); + entry.links.retain(|l| l.team.is_none()); + if let Err(e) = write_json(&path, &entry.links) { + // Still refused at the door ([`team_admits`]) while the file isn't shared. + log::error!("file {file}: its team links couldn't be revoked: {e}"); + entry.links = before; + return Vec::new(); + } + log::info!("revoked the team links of file {file}: it isn't shared now"); + gone + } +} + +/// Whether link `at` of `entry` lets people in, on a server whose team list gives `team`: a team +/// link only while the list is on with its access and the file is shared; any other link always. +pub(super) fn team_admits(team: Option, entry: &Entry, at: usize) -> bool { + entry.links.get(at).is_some_and(|l| l.team.is_none() || (team == Some(l.access) && shared(entry))) +} diff --git a/apps/figcraft-server/src/team_tests.rs b/apps/figcraft-server/src/team_tests.rs new file mode 100644 index 000000000..fbe11935b --- /dev/null +++ b/apps/figcraft-server/src/team_tests.rs @@ -0,0 +1,262 @@ +//! The team list (`team_files`) against a real server: off unless the operator turns it on; on, +//! it lists the files shared now (not the ones only published), hands out one team link per file +//! that joins with the list's access, and when the owner stops sharing the file leaves the list, +//! its team link stops working and whoever joined with it is disconnected. Turning the list off, +//! or changing its access, refuses the old team links at once. + +use std::net::SocketAddr; + +use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ResolveTeam, ServerMsg, TeamFile}; +use figcraft_engine::live::Status; +use serde_json::{Value, json}; + +use crate::config::Config; +use crate::store::team::{NO_TEAM, NOT_SHARED}; +use crate::test_util::{Raw, TempDir, config, join, live, publish, pump, session}; + +fn team(dir: &std::path::Path, access: &str) -> Config { + Config { open_publish: true, team_files: access.into(), web_url: "https://figcraft.example.com/app/".into(), ..config(dir) } +} + +fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// One request on an open hosting connection that names no file (anyone's). +fn ask(addr: SocketAddr, req: Admin) -> AdminReply { + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() })); + assert!(matches!(raw.next(), ServerMsg::Hosting(_))); + raw.send(&ClientMsg::Admin(req)); + match raw.next() { + ServerMsg::Admin(r) => *r, + other => panic!("answered {other:?}"), + } +} + +fn listed(addr: SocketAddr) -> (String, Vec) { + match ask(addr, Admin::Team) { + AdminReply::Team { web, files } => (web, files), + other => panic!("not a team list: {other:?}"), + } +} + +/// The team link of `file`, as a join by the team list asks for it: its token. +fn team_link(addr: SocketAddr, file: &str) -> Result { + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::ResolveTeam(ResolveTeam { protocol: PROTOCOL, client_version: "test".into(), file: file.into() })); + match raw.next() { + ServerMsg::Resolved { file: f, token } if f == file => Ok(token), + ServerMsg::Refused { reason } => Err(reason), + other => panic!("answered {other:?}"), + } +} + +/// The team link's entry in the file's links (as `figcraft-server links list` shows them). +fn team_entry(dir: &std::path::Path, file: &str) -> (Access, String) { + let links: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("files").join(file).join("links.json")).unwrap()).unwrap(); + let l = links.as_array().unwrap().iter().find(|l| l["team"].is_string()).expect("a team link"); + (Access::parse(l["access"].as_str().unwrap()).unwrap(), l["label"].as_str().unwrap().to_string()) +} + +fn invite(addr: SocketAddr, file: &str, token: &str) -> String { + format!("ws://{addr}/?file={file}&token={token}") +} + +/// Ada shares a file online: the app with the file live, and the file's id. +fn share(addr: SocketAddr, name: &str) -> (figcraft_engine::Session, String) { + let mut a = session("Ada"); + ex(&mut a, "pref.publishServer", json!({"url": format!("ws://{addr}/")})); + ex(&mut a, "file.rename", json!({"name": name})); + ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})); + ex(&mut a, "collab.share", json!({})); + pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some())); + let file = a.prefs.online_files[0].file.clone(); + (a, file) +} + +fn layers(s: &figcraft_engine::Session) -> usize { + s.doc().map_or(0, |d| d.node_count()) +} + +fn refused(s: &figcraft_engine::Session) -> bool { + matches!(&s.live().status, Status::Failed { reason } if reason.contains("isn't valid")) +} + +#[test] +fn there_is_no_team_list_unless_the_operator_turns_it_on() { + let dir = TempDir::new("team-off"); + let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); + let (_a, file) = share(server.addr(), "Homepage"); + assert_eq!(ask(server.addr(), Admin::Team), AdminReply::Failed { reason: NO_TEAM.into() }); + assert_eq!(team_link(server.addr(), &file), Err(NO_TEAM.into())); + for bad in ["owner", "edit view"] { + assert!(team(dir.path(), bad).check().is_err(), "team_files = {bad:?} is refused"); + } + for bad in ["ftp://x/", "https://x/app/?k=1", "https://x/app/#a", "https://x y/"] { + assert!(Config { web_url: bad.into(), ..config(dir.path()) }.check().is_err(), "web_url = {bad:?} is refused"); + } + assert!(team(dir.path(), " View ").check().is_ok()); +} + +#[test] +fn the_team_list_has_the_files_shared_now_and_their_team_link_joins() { + let dir = TempDir::new("team-list"); + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (mut a, file) = share(addr, "Homepage"); + // Published with the key but not shared: not on the list. + let mut c = session("Cy"); + let unshared = publish(&mut c, dir.path(), addr).file; + let (web, files) = listed(addr); + assert_eq!(web, "https://figcraft.example.com/app/"); + let ids: Vec<(&str, &str, Access)> = files.iter().map(|f| (f.file.id.as_str(), f.file.name.as_str(), f.access)).collect(); + assert_eq!(ids, vec![(file.as_str(), "Homepage", Access::Edit)]); + assert!(team_link(addr, &unshared).is_err_and(|e| e == NOT_SHARED)); + assert!(team_link(addr, "nonesuch").is_err_and(|e| e == NOT_SHARED)); + // The team link: made once, the same for everyone after. + let token = team_link(addr, &file).unwrap(); + assert_eq!(team_entry(dir.path(), &file), (Access::Edit, "Team (edit)".to_string())); + assert_eq!(team_link(addr, &file).unwrap(), token); + // Bob joins with it and edits; Ada sees the change. + let mut b = join(&invite(addr, &file, &token), "Bob"); + pump(&mut [&mut a, &mut b], "Bob in", |s| live(s[1]) && s[0].live().peers.len() == 1); + let before = layers(&a); + ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 30, "height": 30})); + pump(&mut [&mut a, &mut b], "Bob's ellipse at Ada's", |s| layers(s[0]) == before + 1); + // The team link isn't a share of its own: the file isn't listed twice, and the owner's + // record still has just the links Ada made. + assert_eq!(listed(addr).1.len(), 1); +} + +#[test] +fn stopping_sharing_takes_the_file_off_the_list_and_ends_the_team_link() { + let dir = TempDir::new("team-unshare"); + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (mut a, file) = share(addr, "Homepage"); + let token = team_link(addr, &file).unwrap(); + let mut b = join(&invite(addr, &file, &token), "Bob"); + pump(&mut [&mut a, &mut b], "Bob in", |s| live(s[1])); + assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false); + // Bob is out, as people who joined with Ada's own links are. + pump(&mut [&mut a, &mut b], "Bob disconnected", |s| matches!(s[1].live().status, Status::Failed { .. } | Status::Off)); + assert!(listed(addr).1.is_empty()); + assert!(team_link(addr, &file).is_err_and(|e| e == NOT_SHARED)); + let mut again = join(&invite(addr, &file, &token), "Bob"); + pump(&mut [&mut again], "the old team link refused", |s| refused(s[0])); + // Sharing again lists it again, with a new team link. + assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "share"}))["shared"], true); + assert_eq!(listed(addr).1.len(), 1); + assert_ne!(team_link(addr, &file).unwrap(), token); +} + +#[test] +fn turning_the_list_off_or_changing_its_access_refuses_old_team_links() { + let dir = TempDir::new("team-config"); + let mut server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (a, file) = share(addr, "Homepage"); + let edit = team_link(addr, &file).unwrap(); + drop(a); + server.stop(); + // View only now: the edit team link no longer joins; the new one is view only. + let server = crate::start(&team(dir.path(), "view")).unwrap(); + let addr = server.addr(); + let mut b = join(&invite(addr, &file, &edit), "Bob"); + pump(&mut [&mut b], "the edit team link refused", |s| refused(s[0])); + let view = team_link(addr, &file).unwrap(); + assert_eq!(team_entry(dir.path(), &file).0, Access::View); + let mut b = join(&invite(addr, &file, &view), "Bob"); + pump(&mut [&mut b], "Bob in, view only", |s| live(s[0]) && s[0].live_summary()["access"] == "view"); + drop(b); + drop(server); + // Off: the list is gone and its links with it, also from the file's links. + let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); + let mut b = join(&invite(server.addr(), &file, &view), "Bob"); + pump(&mut [&mut b], "the team link refused", |s| refused(s[0])); + drop(b); + drop(server); + assert!(!std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap().contains(r#""team""#)); + // On again with the old access: the old links stay refused; the next join gets a new one. + let server = crate::start(&team(dir.path(), "view")).unwrap(); + let mut b = join(&invite(server.addr(), &file, &view), "Bob"); + pump(&mut [&mut b], "the old view team link still refused", |s| refused(s[0])); + assert_ne!(team_link(server.addr(), &file).unwrap(), view); +} + +#[test] +fn a_team_link_never_handed_out_again_stays_refused_after_the_setting_goes_back() { + let dir = TempDir::new("team-back"); + let mut server = crate::start(&team(dir.path(), "edit")).unwrap(); + let (a, file) = share(server.addr(), "Homepage"); + let edit = team_link(server.addr(), &file).unwrap(); + drop(a); + server.stop(); + // View only for a while, and nobody asks for the view team link… + drop(crate::start(&team(dir.path(), "view")).unwrap()); + // …then edit again: the edit team link of before doesn't come back. + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let mut b = join(&invite(server.addr(), &file, &edit), "Bob"); + pump(&mut [&mut b], "the old edit team link refused", |s| refused(s[0])); + assert_ne!(team_link(server.addr(), &file).unwrap(), edit); +} + +#[test] +fn a_team_list_link_joins_without_a_token_while_the_file_is_shared() { + let dir = TempDir::new("team-join"); + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (mut a, file) = share(addr, "Homepage"); + // What the team list gives people to click or paste: no token in it. + let link = format!("figcraft://join?host={addr}&file={file}&team=1"); + let mut b = join(&link, "Bob"); + pump(&mut [&mut a, &mut b], "Bob in by the team list", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!(b.live_summary()["access"], "edit"); + let failed = |s: &figcraft_engine::Session, why: &str| matches!(&s.live().status, Status::Failed { reason } if reason.contains(why)); + let mut c = join(&format!("figcraft://join?host={addr}&file=0123456789abcdef&team=1"), "Cy"); + pump(&mut [&mut c], "a file that isn't shared refused", |s| failed(s[0], "isn't shared now")); + // A server without a team list says so. + let dir = TempDir::new("team-join-off"); + let off = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); + let mut d = join(&format!("figcraft://join?host={}&file={file}&team=1", off.addr()), "Di"); + pump(&mut [&mut d], "no team list", |s| failed(s[0], "keeps no team list")); +} + +#[test] +fn the_apps_online_files_list_the_team_list_and_open_its_files() { + let dir = TempDir::new("team-app"); + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let addr = server.addr(); + let (mut a, file) = share(addr, "Homepage"); + // Bob has the same server in Preferences and nothing of his own online. + let mut b = session("Bob"); + ex(&mut b, "pref.publishServer", json!({"url": format!("ws://{addr}/")})); + assert_eq!(ex(&mut b, "collab.files", json!({}))["team"], Value::Null, "nothing asked without refresh"); + let list = ex(&mut b, "collab.files", json!({"refresh": true})); + let t = &list["team"]; + assert_eq!( + (&t["state"], &t["web"], &t["files"][0]["file"], &t["files"][0]["name"]), + (&json!("listed"), &json!("https://figcraft.example.com/app/"), &json!(file), &json!("Homepage")) + ); + let link = t["files"][0]["link"].as_str().unwrap().to_string(); + assert_eq!(link, format!("figcraft://join?server=ws://{addr}/&file={file}&team=1")); + assert_eq!( + t["files"][0]["webLink"], + json!(format!( + "https://figcraft.example.com/app/#join=figcraft%3A%2F%2Fjoin%3Fserver%3Dws%3A%2F%2F{}%2F%26file%3D{file}%26team%3D1", + addr.to_string().replace(':', "%3A") + )) + ); + assert!(!list.to_string().contains("token"), "no team link token in the result: {list}"); + ex(&mut b, "collab.join", json!({"link": link})); + pump(&mut [&mut a, &mut b], "Bob in from the list", |s| live(s[1]) && s[0].live().peers.len() == 1); + // Another server in Preferences: its list isn't this one. + ex(&mut b, "pref.publishServer", json!({"url": "wss://elsewhere.example/"})); + assert_eq!(ex(&mut b, "collab.files", json!({}))["team"], Value::Null); + // A server without a team list says so. + let dir = TempDir::new("team-app-off"); + let off = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); + ex(&mut b, "pref.publishServer", json!({"url": format!("ws://{}/", off.addr())})); + assert_eq!(ex(&mut b, "collab.files", json!({"refresh": true}))["team"]["state"], "none"); +} diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs index 88a03b2be..547ccc34a 100644 --- a/apps/figcraft-web/src/query.rs +++ b/apps/figcraft-web/src/query.rs @@ -1,4 +1,5 @@ -//! The page's URL: the invite link its fragment names, and the flags its query sets. +//! The page's URL: the invite link its fragment names, and the flags its query sets; and the server +//! the page names for itself. /// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still /// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out @@ -14,6 +15,16 @@ pub fn join_link(fragment: &str) -> Option<&str> { fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) } +/// The server a page names for itself in `` (a +/// host serving the browser build beside its own `figcraft-server`): the page's default for online +/// sharing, join codes and the team list. Only a `ws://` or `wss://` address without a query (no +/// key can ride in it), at most 512 characters; anything else is ignored. +pub fn page_server(content: &str) -> Option { + let c = content.trim(); + let scheme = c.starts_with("wss://") || c.starts_with("ws://"); + (scheme && c.len() <= 512 && !c.contains(|ch: char| ch.is_whitespace() || ch.is_control() || ch == '?' || ch == '#')).then(|| c.to_string()) +} + /// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`, /// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only /// when it is asked for. diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs index 6113c0cad..28681df68 100644 --- a/apps/figcraft-web/src/query_tests.rs +++ b/apps/figcraft-web/src/query_tests.rs @@ -1,6 +1,6 @@ -//! `#join=` and `?control` in the page's URL. +//! `#join=` and `?control` in the page's URL, and the server the page names for itself. -use crate::query::{control, join_link}; +use crate::query::{control, join_link, page_server}; #[test] fn join_takes_the_rest_of_the_fragment() { @@ -38,3 +38,14 @@ fn control_is_opt_in() { assert!(!control("?webgl")); assert!(!control("?nocontrol&controls"), "only the whole entry name"); } + +#[test] +fn a_page_names_its_own_server_without_a_key() { + assert_eq!(page_server(" wss://figcraft.example/ ").as_deref(), Some("wss://figcraft.example/")); + assert_eq!(page_server("ws://127.0.0.1:47900/").as_deref(), Some("ws://127.0.0.1:47900/")); + for bad in + ["", "https://figcraft.example/", "wss://figcraft.example/?key=secret", "wss://a b/", "wss://x/#y", &format!("wss://{}/", "a".repeat(600))] + { + assert_eq!(page_server(bad), None, "{bad:?} is ignored"); + } +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index e8e7f54ad..550e495c9 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -50,6 +50,10 @@ pub fn start() { } let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); let mut session = Session::new(); + // The host names its own server: the default for sharing, codes and the team list. + if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) { + session.prefs.publish_server = server; + } crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten())); let recovered = restore_unsaved(&mut session); // Live sessions: the browser joins sessions and publishes files to servers; @@ -124,6 +128,12 @@ fn local_storage() -> Option { web_sys::window().and_then(|w| w.local_storage().ok().flatten()) } +/// The `content` of the page's ``, if it has one. +fn page_meta(name: &str) -> Option { + let document = web_sys::window()?.document()?; + document.query_selector(&format!("meta[name=\"{name}\"]")).ok().flatten()?.get_attribute("content") +} + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 4cf10f6a4..7f074f476 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -17,7 +17,7 @@ use tungstenite::{Bytes, Message}; use crate::driver::{Driver, Socket}; use crate::guest::GuestConfig; use crate::inbox::Inbox; -use crate::invite::{Endpoint, Invite}; +use crate::invite::{Endpoint, Invite, Lookup}; use crate::loopback_tests::{fast_limits, quick}; use crate::{NetLink, manage}; @@ -117,13 +117,18 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(e.contains("share it online"), "{e}"); } +/// An invite naming only `server` (what a join code is asked at). +fn code_at(server: &Endpoint) -> Invite { + Invite { server: server.clone(), file: String::new(), token: String::new() } +} + #[test] fn links_can_name_a_join_code() { let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap(); assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", "")); - assert_eq!(found.as_deref(), Some("K7M2QX9TD")); + assert_eq!(found, Some(Lookup::Code("K7M2QX9TD".into()))); let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap(); - assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD"))); + assert_eq!((invite.server, found), (Endpoint::plain("127.0.0.1:47900"), Some(Lookup::Code("K7M2QX9TD".into())))); // A link with a file and a token is read as before. let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None)); @@ -198,7 +203,7 @@ fn a_server_older_than_join_codes_says_so_and_the_links_still_work() { let (server, thread) = old_server(); let limits = fast_limits(); // Asking it about a code: the join fails with what to do instead. - let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err(); + let e = manage::resolve(&code_at(&server), &Lookup::Code("K7M2QX9TD".into()), &limits, Duration::from_secs(5)).unwrap_err(); assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}"); let mut guest = session("Ada"); guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap(); @@ -233,11 +238,11 @@ fn an_app_hosted_session_knows_no_online_codes() { let mut host = session("Hana"); let shared = host.execute("collab.host", &json!({"port": 0})).unwrap(); let server = Endpoint::plain(shared["address"].as_str().unwrap()); - let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err(); + let e = manage::resolve(&code_at(&server), &Lookup::Code("K7M2QX9TD".into()), &fast_limits(), Duration::from_secs(5)).unwrap_err(); assert!(e.contains("hosted by an app"), "{e}"); // Junk in place of a code is refused the same way, and the session goes on. for junk in ["", "💥", &"Z".repeat(4000)] { - assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err()); + assert!(manage::resolve(&code_at(&server), &Lookup::Code(junk.into()), &fast_limits(), Duration::from_secs(5)).is_err()); } assert_eq!(host.live_summary()["status"], "live"); } @@ -268,7 +273,7 @@ impl Socket for Script { fn driver(t0: web_time::Instant) -> (Driver