From def6f84b22b1c74f11f298bcda115186088334af Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 05:04:33 +0700 Subject: [PATCH] M11: plugin directory on figcraft-server A self-hosted plugin directory, with no third-party service: `figcraft-server` keeps plugin bundles, the app browses, installs and updates from it, and `figcraft-cli` publishes to it. - **Bundles** (`figcraft_plugin::bundle`): a plugin's manifest, module and up to 32 plain-named assets in one uncompressed, length-prefixed binary form (nothing can expand beyond the bytes sent). Its SHA-256 names it. `Bundle::decode` never allocates beyond its input and refuses trailing bytes; `Bundle::check` parses the manifest (refusing a plugin API this build doesn't run), requires a version of 1-4 numbers, compiles the module without running it (only the sandbox's imports allowed) and checks asset names (no paths, no dot files, no Windows device names, unique ignoring case). Manifests gain an optional `author`. - **Protocol**: a new connection kind next to managing and team-library connections: `OpenPlugins {key}` then `Plugins {list | fetch | publish | unpublish}`. Browsing needs no credential; publishing and unpublishing need the server key (a wrong key is refused). Older servers refuse the connection and the app says the server has no plugin directory. The protocol version is unchanged; old clients are unaffected. - **Server** (`apps/figcraft-server/src/directory.rs`): `/plugins/index.json` plus `/plugins/.bin`, so no publisher-chosen name reaches the file system. Every publish is checked in full and must be a newer version; caps `max_plugins` (versions, 0 turns it off), `max_plugin_mb` (<= 32), `max_plugins_mb`. Fetches re-hash the file before sending (a damaged bundle is never sent) and share a download budget (~32 MiB/s) so a flood is refused, not queued. Damaged index entries are skipped and reported, never deleted; an unreadable index lists nothing and refuses writes until the operator fixes it. Operator moderation: `figcraft-server plugins [list]` and `plugins remove ID [--version V]` (offline or through the running server). Logs name ids and versions, never keys. - **App**: Plugins > Manage plugins gets Installed / Browse tabs. Browse lists a server's plugins (version, author as typed, size, access) with a search; nothing is fetched until Browse is pressed. Install / Update open a confirmation listing the permissions asked for, the source and the SHA-256; only its button downloads. The download must match the listed SHA-256 (and the one shown), then is checked like an imported plugin before anything is written. Installed plugins live per user under `/FigCraft/Plugins//` and run in the same sandbox; Remove deletes the folder. - **Commands**: `plugin.directory.list {server?, query?}`, `plugin.directory.install {id, version?, confirm, server?, sha256?}` (refused without `confirm: true`; never opens a dialog), `plugin.directory.publish {path | plugin, server}`, `plugin.directory.unpublish {id, version?, server}`. `ui.set {pluginsTab, pluginConfirm}` on the control channel. - **CLI**: `figcraft-cli plugin publish FOLDER --server ws://host:port/` with the key from `FIGCRAFT_SERVER_KEY` (or `?key=`), never printed. - Docs: plugins.md (directories, publishing, commands, manifest), server.md (operating the directory: access, checks, caps, moderation, backups, security), collab-protocol.md, mcp.md, cli.md, control-protocol.md, target-app-parity area 18 and Ecosystem, ROADMAP, roadmap.md M11, gaps.md gap 6. Plugins & widgets 55%/32% -> 60%/35%, 10-30 h -> 8-26 h. --- Cargo.lock | 3 + ROADMAP.md | 17 +- apps/figcraft-cli/src/main.rs | 40 ++- apps/figcraft-server/Cargo.toml | 3 + apps/figcraft-server/src/cli.rs | 68 ++++- apps/figcraft-server/src/config.rs | 24 ++ apps/figcraft-server/src/directory.rs | 286 ++++++++++++++++++ apps/figcraft-server/src/directory_tests.rs | 264 +++++++++++++++++ apps/figcraft-server/src/lib.rs | 3 + apps/figcraft-server/src/server_tests.rs | 6 + apps/figcraft-server/src/store.rs | 21 +- apps/figcraft/src/main.rs | 2 + crates/collab-net/src/hub.rs | 41 ++- crates/collab-net/src/join.rs | 2 +- crates/collab-net/src/link.rs | 8 +- crates/collab-net/src/manage.rs | 32 +- crates/collab-net/src/net.rs | 9 +- crates/collab/src/client.rs | 4 +- crates/collab/src/codec_tests.rs | 26 +- crates/collab/src/message.rs | 82 ++++++ crates/collab/src/server.rs | 7 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 7 + crates/engine/src/live.rs | 8 +- crates/engine/src/plugin_directory.rs | 308 ++++++++++++++++++++ crates/engine/src/plugin_directory_tests.rs | 254 ++++++++++++++++ crates/engine/src/plugins.rs | 41 ++- crates/engine/src/tests.rs | 3 + crates/plugin/Cargo.toml | 2 + crates/plugin/src/bundle.rs | 206 +++++++++++++ crates/plugin/src/bundle_tests.rs | 102 +++++++ crates/plugin/src/lib.rs | 3 + crates/plugin/src/manifest.rs | 4 + crates/ui-egui/locales/en.toml | 22 ++ crates/ui-egui/locales/es.toml | 26 ++ crates/ui-egui/src/control.rs | 11 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/plugins_browse_tests.rs | 112 +++++++ crates/ui-egui/src/plugins_ui.rs | 274 +++++++++++++++-- docs/cli.md | 12 +- docs/collab-protocol.md | 21 ++ docs/control-protocol.md | 6 +- docs/gaps.md | 17 +- docs/mcp.md | 14 + docs/plugins.md | 68 ++++- docs/roadmap.md | 5 +- docs/server.md | 55 +++- docs/target-app-parity.md | 23 +- 48 files changed, 2460 insertions(+), 98 deletions(-) create mode 100644 apps/figcraft-server/src/directory.rs create mode 100644 apps/figcraft-server/src/directory_tests.rs create mode 100644 crates/engine/src/plugin_directory.rs create mode 100644 crates/engine/src/plugin_directory_tests.rs create mode 100644 crates/plugin/src/bundle.rs create mode 100644 crates/plugin/src/bundle_tests.rs create mode 100644 crates/ui-egui/src/plugins_browse_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 7b59389d..8fd16e0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,6 +1606,7 @@ dependencies = [ "figcraft-color", "serde", "serde_json", + "sha2", "thiserror 2.0.21", "wasmi", "wat", @@ -1641,12 +1642,14 @@ dependencies = [ "figcraft-doc", "figcraft-engine", "figcraft-format", + "figcraft-plugin", "log", "serde", "serde_json", "sha2", "toml", "tungstenite", + "wat", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 36db7b74..124a1083 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-12 · **Change:** minor (Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~49%** | estimated: weekly areas 72.6% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~155–280 h** one agent | estimated | -| Remaining to full parity | **~268–528 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~266–524 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~273–530 h | multiplayer (42–84), localization (32–55), plugins (10–30), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~271–526 h | multiplayer (42–84), localization (32–55), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -65,7 +65,7 @@ native speakers' reviews of the six language drafts need people. | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | | Localization | 35% (layer; English; Spanish, French, German, Brazilian Portuguese, Japanese, Korean drafts; CJK UI fonts) | 32–55 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | -| Ecosystem / plugins | 34% (own WASM plugins with panels, settings and widgets) | 10–30 | [gaps](docs/gaps.md) | +| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | ## Features @@ -89,7 +89,7 @@ native speakers' reviews of the six language drafts need people. | Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 55% | 32% | 10–30 | [plugins](docs/plugins.md) | +| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -130,6 +130,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key + (`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a + confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are + checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live + per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with + `figcraft-server plugins`. Plugins & widgets 60%. - **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin, validated and built with locked layers inside; clicking a part it named calls it back with the widget's state @@ -230,6 +236,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | | 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h | | 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h | diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 9337b970..db972ad6 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -1,5 +1,6 @@ //! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run -//! command scripts, list commands, serve MCP on stdio and measure performance. +//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin +//! directory and measure performance. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod args; @@ -23,6 +24,7 @@ USAGE: figcraft-cli run [--in ] [--out ] figcraft-cli commands [--json] [--filter TEXT] figcraft-cli mcp [--attach PORT] + figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] figcraft-cli help | --help | --version @@ -32,6 +34,9 @@ per top-level frame into the directory, or one file when has an imag is given; PDFs take print options (a bleed like 3mm or 0.125in, crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of {\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step. +`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) +to a figcraft-server's plugin directory as a new version; the server's key comes from the address or +the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history). See docs/cli.md and docs/mcp.md."; fn main() -> ExitCode { @@ -122,6 +127,16 @@ fn run(argv: &[String]) -> CliResult { } mcp(a.value("attach").as_deref()) } + "plugin" => { + let a = Args::parse(rest, &["server"], &[])?; + match a.positional.as_slice() { + [verb, folder] if verb == "publish" => { + let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?; + publish_plugin(folder, &server) + } + _ => Err(CliError::Usage("expected: plugin publish --server ws://host:port/".into())), + } + } "perf" => { let a = Args::parse(rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); @@ -155,6 +170,29 @@ fn commands(as_json: bool, filter: Option<&str>) { } } +/// Publish the plugin folder `folder` to the plugin directory at `server` (the key from the address +/// or `FIGCRAFT_SERVER_KEY`); never prints the key. +fn publish_plugin(folder: &str, server: &str) -> CliResult { + let address = server.split('?').next().unwrap_or_default().to_string(); + let server = match std::env::var("FIGCRAFT_SERVER_KEY") { + Ok(key) if !server.contains("key=") && !key.trim().is_empty() => format!("{address}?key={}", key.trim()), + _ => server.to_string(), + }; + let mut h = figcraft_mcp::Headless::new(); + use figcraft_mcp::Backend as _; + let r = h.call("engine.execute", json!({"command": "plugin.directory.publish", "params": {"path": folder, "server": server}}))?; + let e = &r["published"]; + println!( + "Published {} {} ({}) to {address}: {} bytes, SHA-256 {}", + e["name"].as_str().unwrap_or_default(), + e["version"].as_str().unwrap_or_default(), + e["id"].as_str().unwrap_or_default(), + e["bytes"], + e["sha256"].as_str().unwrap_or_default() + ); + Ok(ExitCode::SUCCESS) +} + fn mcp(attach: Option<&str>) -> CliResult { let backend: Box = match attach { Some(p) => { diff --git a/apps/figcraft-server/Cargo.toml b/apps/figcraft-server/Cargo.toml index 92861a23..3098bdbd 100644 --- a/apps/figcraft-server/Cargo.toml +++ b/apps/figcraft-server/Cargo.toml @@ -21,6 +21,8 @@ figcraft-collab = { workspace = true } figcraft-collab-net = { workspace = true } # Checking a team library before keeping it (read as files using it read it). figcraft-format = { workspace = true } +# Checking plugin bundles before the plugin directory keeps them (never run here). +figcraft-plugin = { workspace = true } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -32,6 +34,7 @@ env_logger = { version = "0.11", default-features = false, features = ["humantim ctrlc = { version = "3", features = ["termination"] } [dev-dependencies] +wat = { workspace = true } figcraft-engine = { workspace = true } figcraft-doc = { workspace = true } tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index 95ebdc18..5450288d 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -1,5 +1,5 @@ -//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data -//! directory. When a server is running on the directory they ask it (so revoking a link +//! The command line: `figcraft-server` serves; `key`, `files`, `links` and `plugins` manage the +//! data directory. When a server is running on the directory they ask it (so revoking a link //! disconnects whoever joined with it at once); otherwise they change the directory themselves. use std::fmt::Write as _; @@ -7,7 +7,7 @@ use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::time::Duration; -use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink}; +use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq}; use figcraft_collab_net::hub::Backend; use figcraft_collab_net::{Endpoint, Invite, manage}; use serde::{Deserialize, Serialize}; @@ -30,13 +30,16 @@ Usage: figcraft-server links list FILE a file's share links figcraft-server links create FILE --access edit|view [--label TEXT] figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected) + figcraft-server plugins [list] the plugin directory: every plugin and its versions + figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one) Options (also keys of the --config TOML file): --listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network) --data DIR the data directory (default ./figcraft-data) --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_connections, max_people, compact_after, compact_mb, max_plugins, + max_plugin_mb, max_plugins_mb -h, --help this text -V, --version the version @@ -53,6 +56,8 @@ pub enum Command { Links { file: String }, CreateLink { file: String, access: Access, label: String }, Revoke { file: String, link: String }, + Plugins, + RemovePlugin { id: String, version: Option }, Help, Version, } @@ -75,11 +80,14 @@ pub fn parse(args: &[String]) -> Result { let mut access = None; let mut label = String::new(); let mut rotate = false; + let mut version = None; let mut it = args.iter(); while let Some(a) = it.next() { let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value")); match a.as_str() { "-h" | "--help" => return Ok(Cli { config, command: Command::Help }), + // After `plugins remove ID`: the version to unpublish. + "--version" if words.first().is_some_and(|w| w == "plugins") => version = Some(value("--version")?), "-V" | "--version" => return Ok(Cli { config, command: Command::Version }), "--config" => { value("--config")?; @@ -105,6 +113,8 @@ pub fn parse(args: &[String]) -> Result { Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label } } ["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() }, + ["plugins"] | ["plugins", "list"] => Command::Plugins, + ["plugins", "remove", id] => Command::RemovePlugin { id: id.to_string(), version }, _ => return Err(format!("unknown command `{}`", words.join(" "))), }; config.check()?; @@ -173,9 +183,59 @@ pub fn run(cli: &Cli) -> Result { AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")), other => unexpected(other), }, + Command::Plugins => match directory(cfg, PluginReq::List { query: String::new() })? { + PluginReply::List(entries) => Ok(plugins_table(&entries)), + other => unexpected_plugins(other), + }, + Command::RemovePlugin { id, version } => match directory(cfg, PluginReq::Unpublish { id: id.clone(), version: version.clone() })? { + PluginReply::Removed(versions) => Ok(format!("Unpublished {id} {}.\n", versions.join(", "))), + other => unexpected_plugins(other), + }, } } +fn unexpected_plugins(reply: PluginReply) -> Result { + match reply { + PluginReply::Failed { reason } => Err(reason), + _ => Err("the server answered something else".into()), + } +} + +/// Do one plugin directory request with the server key: through the running server, or on the +/// data directory. +fn directory(cfg: &Config, req: PluginReq) -> Result { + match Store::try_open(&cfg.data, cfg.store())? { + Some(mut store) => Ok(store.directory().handle(true, req)), + None => { + let addr = running(&cfg.data)?; + let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?; + manage::plugins(&Endpoint::plain(addr.to_string()), key.trim(), req, &cfg.net(), Duration::from_secs(5)) + } + } +} + +fn plugins_table(entries: &[PluginEntry]) -> String { + if entries.is_empty() { + return "No plugins yet (publish one with `figcraft-cli plugin publish FOLDER --server ws://host:port/?key=KEY`).\n".into(); + } + let mut out = + String::from("ID VERSIONS NAME AUTHOR ACCESS SIZE SHA-256\n"); + for e in entries { + let _ = writeln!( + out, + "{:<32} {:<18} {:<20} {:<16} {:<6} {:<9} {}", + elide(&e.id, 32), + elide(&e.versions.join(","), 18), + elide(&e.name, 20), + elide(&e.author, 16), + e.access, + size(e.bytes), + e.sha256.get(..16).unwrap_or_default() + ); + } + out +} + fn unexpected(reply: AdminReply) -> Result { match reply { AdminReply::Failed { reason } => Err(reason), diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index c7f4da92..f1f57e90 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use figcraft_collab_net::{Endpoint, NetLimits}; use serde::Deserialize; +use crate::directory::DirectoryConfig; use crate::store::StoreConfig; /// The port the server listens on unless told otherwise (the app's sessions try it first too). @@ -37,6 +38,12 @@ pub struct Config { pub compact_after: usize, /// …or once the log is this large (MiB). pub compact_mb: u64, + /// Most plugin versions the plugin directory keeps (0 turns it off). + pub max_plugins: usize, + /// Largest plugin bundle, in MiB. + pub max_plugin_mb: u64, + /// Most MiB of plugin bundles kept. + pub max_plugins_mb: u64, } impl Default for Config { @@ -52,6 +59,9 @@ impl Default for Config { max_people: 256, compact_after: 1000, compact_mb: 16, + max_plugins: 500, + max_plugin_mb: 16, + max_plugins_mb: 1024, } } } @@ -90,6 +100,8 @@ impl Config { ("max_people", self.max_people as u64), ("compact_after", self.compact_after as u64), ("compact_mb", self.compact_mb), + ("max_plugin_mb", self.max_plugin_mb), + ("max_plugins_mb", self.max_plugins_mb), ] { if v == 0 { return Err(format!("`{name}` must be at least 1")); @@ -98,6 +110,13 @@ impl Config { if self.max_file_mb > 4096 || self.compact_mb > 4096 { return Err("sizes are in MiB and at most 4096".into()); } + // A bundle travels in one message (at most 64 MiB) next to its entry. + if self.max_plugin_mb > 32 { + return Err("`max_plugin_mb` is at most 32".into()); + } + if self.max_plugins > 10_000 || self.max_plugins_mb > 1 << 20 { + return Err("`max_plugins` is at most 10000 and `max_plugins_mb` at most 1048576".into()); + } Ok(()) } @@ -111,6 +130,11 @@ impl Config { compact_after: self.compact_after, compact_bytes: mib(self.compact_mb), core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }, + directory: DirectoryConfig { + max_plugins: self.max_plugins, + max_plugin_bytes: mib(self.max_plugin_mb), + max_total_bytes: mib(self.max_plugins_mb), + }, } } diff --git a/apps/figcraft-server/src/directory.rs b/apps/figcraft-server/src/directory.rs new file mode 100644 index 00000000..e121c628 --- /dev/null +++ b/apps/figcraft-server/src/directory.rs @@ -0,0 +1,286 @@ +//! The plugin directory: plugin bundles (`figcraft_plugin::bundle`) published to the server with +//! its key, listed, searched and fetched by anyone who can reach it, and unpublished with the key +//! (there are no accounts: whoever has the key is the publisher, and the operator moderates). +//! +//! ```text +//! /plugins/index.json every version kept: manifest fields, size, SHA-256, when +//! /plugins/.bin a bundle, named by the SHA-256 of its bytes +//! ``` +//! +//! A bundle is checked in full before it is kept (it decodes within the size cap, its manifest +//! parses and asks for a plugin API this build runs, its version is newer than the one kept, its +//! module compiles and imports only what the sandbox provides, its asset names are plain) and is +//! never run here. Bundles are named by their hash, so no name a publisher picks reaches the file +//! system, and a fetch reads the file back and checks its hash before sending it. Caps: bundle +//! size, versions kept and bytes kept; fetches share a budget of bytes per second, so a crowd of +//! downloads can't queue more than a bounded amount of data. + +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use figcraft_collab::message::{PluginEntry, PluginReply, PluginReq}; +use figcraft_collab_net::net::Bucket; +use figcraft_plugin::bundle::{Bundle, compare_versions, parse_version, sha256_hex}; +use figcraft_plugin::manifest::Access; + +use crate::disk; +use crate::store::now; + +pub const PLUGINS: &str = "plugins"; +pub const INDEX: &str = "index.json"; +/// Largest `index.json` read, longest search, and most bytes of entries one listing carries. +const MAX_INDEX_FILE: u64 = 64 << 20; +const MAX_QUERY: usize = 200; +const MAX_LISTING: usize = 32 << 20; +/// What fetches may send, in MiB per second on average (each costs at least one). +const SERVE_MIB_PER_SEC: f64 = 32.0; +const MIB: f64 = (1 << 20) as f64; + +/// What the directory enforces. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DirectoryConfig { + /// Most versions kept (all plugins together); 0 turns the directory off. + pub max_plugins: usize, + /// Largest bundle. + pub max_plugin_bytes: u64, + /// Most bytes of bundles kept. + pub max_total_bytes: u64, +} + +/// The plugins a server keeps. +pub struct Directory { + dir: PathBuf, + cfg: DirectoryConfig, + entries: Vec, + served: Bucket, + /// `index.json` couldn't be read: nothing is written over it until the operator fixes it. + broken: bool, +} + +/// A plugin id as manifests have them (letters, digits, `.`, `-`, `_`; 1–128). +fn valid_id(s: &str) -> bool { + (1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) +} + +fn valid_sha(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Newest first. +fn by_version(a: &PluginEntry, b: &PluginEntry) -> Ordering { + compare_versions(&b.version, &a.version).unwrap_or(Ordering::Equal) +} + +impl Directory { + /// The directory under `root` (made when the first plugin is published). Entries whose bundle + /// is missing or the wrong size are left out and reported, never deleted. + pub fn open(root: &Path, cfg: DirectoryConfig) -> (Directory, Vec) { + let dir = root.join(PLUGINS); + let burst = (2 * cfg.max_plugin_bytes) as f64 / MIB; + let mut d = Directory { dir, cfg, entries: Vec::new(), served: Bucket::new(SERVE_MIB_PER_SEC, burst.max(64.0)), broken: false }; + let mut problems = Vec::new(); + let index = d.dir.join(INDEX); + let _ = fs::remove_file(disk::temp_of(&index)); + if !index.exists() { + return (d, problems); + } + let all: Vec = + match disk::read_capped(&index, MAX_INDEX_FILE).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())) { + Ok(all) => all, + Err(e) => { + problems.push(format!("plugin directory: {INDEX} can't be read ({e}); no plugins are listed or published until it is fixed")); + d.broken = true; + return (d, problems); + } + }; + for e in all { + let size = fs::metadata(d.file(&e.sha256)).map(|m| m.len()).ok(); + if !valid_id(&e.id) || parse_version(&e.version).is_none() || !valid_sha(&e.sha256) || size != Some(e.bytes) { + problems.push(format!( + "plugin directory: skipped {} {}: its bundle is missing or damaged", + e.id.escape_debug(), + e.version.escape_debug() + )); + continue; + } + d.entries.push(e); + } + (d, problems) + } + + fn file(&self, sha: &str) -> PathBuf { + self.dir.join(format!("{sha}.bin")) + } + + /// Every version kept. + pub fn entries(&self) -> &[PluginEntry] { + &self.entries + } + + /// Bytes of bundles kept (a bundle published under two versions counts once). + fn total_bytes(&self) -> u64 { + let files: BTreeMap<&str, u64> = self.entries.iter().map(|e| (e.sha256.as_str(), e.bytes)).collect(); + files.values().sum() + } + + /// Answer one request; `publisher`: the connection has the server's key. + pub fn handle(&mut self, publisher: bool, req: PluginReq) -> PluginReply { + let failed = |reason: String| PluginReply::Failed { reason }; + if self.cfg.max_plugins == 0 { + return failed("this server's plugin directory is turned off".into()); + } + let needs_key = || failed("publishing and unpublishing plugins needs the server's key (ws://host:port/?key=…)".into()); + match req { + PluginReq::List { query } => PluginReply::List(self.list(&query)), + PluginReq::Fetch { id, version } => match self.fetch(&id, version.as_deref()) { + Ok((entry, bundle)) => PluginReply::Bundle { entry, bundle }, + Err(e) => failed(e), + }, + PluginReq::Publish { .. } | PluginReq::Unpublish { .. } if !publisher => needs_key(), + PluginReq::Publish { bundle } => self.publish(&bundle).map_or_else(failed, PluginReply::Published), + PluginReq::Unpublish { id, version } => self.unpublish(&id, version.as_deref()).map_or_else(failed, PluginReply::Removed), + } + } + + /// The latest version of each plugin matching `query` (any case, in its id, name, description + /// or author), by name, with the versions kept. + pub fn list(&self, query: &str) -> Vec { + let q: String = query.trim().chars().take(MAX_QUERY).collect::().to_lowercase(); + let mut by_id: BTreeMap<&str, Vec<&PluginEntry>> = BTreeMap::new(); + for e in &self.entries { + by_id.entry(e.id.as_str()).or_default().push(e); + } + let mut out: Vec = by_id + .into_values() + .filter_map(|mut versions| { + versions.sort_by(|a, b| by_version(a, b)); + let mut latest = (*versions.first()?).clone(); + latest.versions = versions.iter().map(|v| v.version.clone()).collect(); + Some(latest) + }) + .filter(|e| q.is_empty() || [&e.id, &e.name, &e.description, &e.author].iter().any(|f| f.to_lowercase().contains(&q))) + .collect(); + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id))); + // A listing stays well within a message. + let mut size = 0; + out.retain(|e| { + size += 256 + e.name.len() + e.description.len() + e.author.len() + e.commands.iter().map(|c| c.len() + 8).sum::(); + size <= MAX_LISTING + }); + out + } + + /// Version `version` (or the latest) of plugin `id`, and its bundle, read back and checked + /// against its hash. + pub fn fetch(&mut self, id: &str, version: Option<&str>) -> Result<(PluginEntry, Vec), String> { + let entry = self + .entries + .iter() + .filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal))) + .min_by(|a, b| by_version(a, b)) + .cloned() + .ok_or_else(|| match version { + Some(v) => format!("the directory has no version {} of that plugin", v.chars().take(32).collect::()), + None => "the directory has no such plugin (it may have been unpublished)".to_string(), + })?; + if !self.served.take_n(Instant::now(), 1.0 + entry.bytes as f64 / MIB) { + return Err("the server is busy sending plugins: try again in a moment".into()); + } + let bytes = disk::read_capped(&self.file(&entry.sha256), self.cfg.max_plugin_bytes.max(entry.bytes))?; + if sha256_hex(&bytes) != entry.sha256 { + log::error!("plugin directory: the bundle of {} {} is damaged on disk; not sending it", entry.id, entry.version); + return Err("that plugin's bundle is damaged on the server; tell the server's owner".into()); + } + Ok((entry, bytes)) + } + + /// Keep a bundle as a new version of its plugin, after checking all of it. + // shortcut: the bundle is checked (its module compiled) on the hub thread, like team libraries; + // move it to a worker if large plugins make other sessions wait noticeably. + pub fn publish(&mut self, bytes: &[u8]) -> Result { + let max = self.cfg.max_plugin_bytes; + if bytes.len() as u64 > max { + return Err(format!("the plugin is {} KiB; this server keeps plugins up to {} KiB", bytes.len() >> 10, max >> 10)); + } + let bundle = Bundle::decode(bytes, usize::try_from(max).unwrap_or(usize::MAX))?; + let m = bundle.check()?; + if let Some(latest) = self.entries.iter().filter(|e| e.id == m.id).min_by(|a, b| by_version(a, b)) + && compare_versions(&m.version, &latest.version) != Some(Ordering::Greater) + { + return Err(format!("version {} of {} is on the server already: publish a newer version", latest.version, m.id)); + } + if self.entries.len() >= self.cfg.max_plugins { + return Err(format!("the directory keeps at most {} plugin versions: unpublish some first", self.cfg.max_plugins)); + } + let sha = sha256_hex(bytes); + let known = self.entries.iter().any(|e| e.sha256 == sha); + if !known && self.total_bytes() + bytes.len() as u64 > self.cfg.max_total_bytes { + return Err(format!("the directory is full ({} MiB): unpublish some plugins first", self.cfg.max_total_bytes >> 20)); + } + let entry = PluginEntry { + id: m.id.clone(), + name: m.name.clone(), + version: m.version.clone(), + description: m.description.clone(), + author: m.author.clone(), + api: m.api, + access: match m.permissions.access { + Access::Read => "read".into(), + Access::Write => "write".into(), + }, + commands: m.permissions.commands.clone(), + widget: m.widget, + bytes: bytes.len() as u64, + sha256: sha.clone(), + published: now(), + versions: Vec::new(), + }; + fs::create_dir_all(&self.dir).map_err(|e| format!("couldn't keep the plugin: {e}"))?; + let file = self.file(&sha); + if !known { + disk::write_atomic(&file, bytes).map_err(|e| format!("couldn't keep the plugin: {e}"))?; + } + self.entries.push(entry.clone()); + if let Err(e) = self.save() { + self.entries.pop(); + return Err(format!("couldn't keep the plugin: {e}")); + } + log::info!("plugin directory: published {} {} ({} bytes)", entry.id, entry.version, entry.bytes); + Ok(entry) + } + + /// Remove version `version` (or every version) of plugin `id`; the versions removed. + pub fn unpublish(&mut self, id: &str, version: Option<&str>) -> Result, String> { + let gone = |e: &PluginEntry| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)); + let (removed, kept): (Vec, Vec) = self.entries.iter().cloned().partition(gone); + if removed.is_empty() { + return Err("the directory has no such plugin or version".into()); + } + let before = std::mem::replace(&mut self.entries, kept); + if let Err(e) = self.save() { + self.entries = before; + return Err(format!("couldn't unpublish: {e}")); + } + for r in &removed { + if !self.entries.iter().any(|e| e.sha256 == r.sha256) + && let Err(e) = fs::remove_file(self.file(&r.sha256)) + { + log::warn!("plugin directory: couldn't delete the bundle of {} {}: {e}", r.id, r.version); + } + } + let versions: Vec = removed.into_iter().map(|e| e.version).collect(); + log::info!("plugin directory: unpublished {id} {}", versions.join(", ")); + Ok(versions) + } + + fn save(&self) -> Result<(), String> { + if self.broken { + return Err(format!("the directory's {INDEX} is damaged; tell the server's owner")); + } + let text = serde_json::to_vec_pretty(&self.entries).map_err(|e| e.to_string())?; + disk::write_atomic(&self.dir.join(INDEX), &text).map_err(|e| e.to_string()) + } +} diff --git a/apps/figcraft-server/src/directory_tests.rs b/apps/figcraft-server/src/directory_tests.rs new file mode 100644 index 00000000..c6be7d97 --- /dev/null +++ b/apps/figcraft-server/src/directory_tests.rs @@ -0,0 +1,264 @@ +//! The plugin directory: publishing (checked in full, key only), listing and searching, fetching +//! (hash-checked, within a budget), unpublishing and its caps in the store; then end to end over +//! loopback: the app's commands publish a plugin folder, another session browses without the key, +//! installs with confirmation, runs, updates and removes it; the raw protocol refuses wrong keys and +//! stray messages; and the server's command line lists and moderates. + +use std::path::Path; + +use figcraft_collab::message::{ClientMsg, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg}; +use figcraft_plugin::bundle::{Asset, Bundle, sha256_hex}; +use serde_json::json; + +use crate::cli::{self, Cli, Command}; +use crate::directory::{DirectoryConfig, INDEX, PLUGINS}; +use crate::store::{Store, StoreConfig}; +use crate::test_util::{Raw, TempDir, config, key, session}; + +fn wasm() -> Vec { + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap() +} + +fn manifest(id: &str, version: &str) -> String { + json!({"id": id, "name": "Tiny tool", "version": version, "author": "Ada Example", "description": "Does very little.", "main": "tiny.wasm", + "permissions": {"access": "write", "commands": ["shape.rectangle"]}}) + .to_string() +} + +fn bundle(id: &str, version: &str) -> Vec { + Bundle { manifest: manifest(id, version), wasm: wasm(), assets: vec![Asset { name: "icon.png".into(), data: vec![7; 10] }] }.encode() +} + +fn cfg(dir: &Path, f: impl Fn(&mut DirectoryConfig)) -> StoreConfig { + let mut c = config(dir).store(); + f(&mut c.directory); + c +} + +const ID: &str = "org.example.tiny"; + +#[test] +fn publish_list_fetch_unpublish_and_restart() { + let dir = TempDir::new("plugins-store"); + let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap(); + let d = store.directory(); + let first = d.publish(&bundle(ID, "1.0.0")).unwrap(); + assert_eq!((first.name.as_str(), first.author.as_str(), first.access.as_str()), ("Tiny tool", "Ada Example", "write")); + assert_eq!(first.commands, ["shape.rectangle"]); + assert_eq!(first.sha256, sha256_hex(&bundle(ID, "1.0.0"))); + assert!(d.publish(&bundle(ID, "1.0.0")).unwrap_err().contains("newer")); + assert!(d.publish(&bundle(ID, "0.9")).unwrap_err().contains("newer")); + d.publish(&bundle(ID, "1.10.0")).unwrap(); + d.publish(&bundle("org.example.other", "2.0.0")).unwrap(); + let all = d.list(""); + assert_eq!(all.len(), 2); + let tiny = all.iter().find(|e| e.id == ID).unwrap(); + assert_eq!((tiny.version.as_str(), tiny.versions.clone()), ("1.10.0", vec!["1.10.0".to_string(), "1.0.0".to_string()])); + assert_eq!(d.list("ADA EXAMPLE").len(), 2, "search covers the author, in any case"); + assert!(d.list("nothing like it").is_empty()); + let (e, bytes) = d.fetch(ID, None).unwrap(); + assert_eq!((e.version.as_str(), sha256_hex(&bytes)), ("1.10.0", e.sha256.clone())); + assert_eq!(d.fetch(ID, Some("1.0")).unwrap().0.version, "1.0.0"); + assert!(d.fetch(ID, Some("3.0.0")).is_err() && d.fetch("org.nope", None).is_err()); + // Unpublishing one version deletes its bundle. + let file = dir.path().join(PLUGINS).join(format!("{}.bin", first.sha256)); + assert!(file.exists()); + assert_eq!(d.unpublish(ID, Some("1.0.0")).unwrap(), ["1.0.0"]); + assert!(!file.exists()); + assert!(d.unpublish(ID, Some("1.0.0")).is_err()); + drop(store); + // Kept across a restart; a bundle damaged on disk is never sent, a missing one is skipped. + let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap(); + assert_eq!(store.directory().entries().len(), 2); + let latest = store.directory().fetch(ID, None).unwrap().0; + let path = dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256)); + let mut damaged = std::fs::read(&path).unwrap(); + let last = damaged.len() - 1; + damaged[last] ^= 0xFF; + std::fs::write(&path, &damaged).unwrap(); + assert!(store.directory().fetch(ID, None).unwrap_err().contains("damaged")); + std::fs::write(&path, b"short").unwrap(); + drop(store); + let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap(); + assert!(store.problems().iter().any(|p| p.contains(ID) && p.contains("damaged")), "{:?}", store.problems()); + drop(store); + // A damaged index lists nothing but deletes nothing. + std::fs::write(dir.path().join(PLUGINS).join(INDEX), b"{not json").unwrap(); + let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap(); + assert!(store.problems().iter().any(|p| p.contains(INDEX))); + let mut store = store; + assert!(store.directory().publish(&bundle(ID, "9.0.0")).unwrap_err().contains("damaged"), "nothing is written over a damaged index"); + assert_eq!(std::fs::read(dir.path().join(PLUGINS).join(INDEX)).unwrap(), b"{not json"); + assert!(dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256)).exists()); +} + +#[test] +fn bad_bundles_caps_and_keys() { + let dir = TempDir::new("plugins-bad"); + let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugin_bytes = 64 << 10)).unwrap(); + let d = store.directory(); + let with = |f: &dyn Fn(&mut Bundle)| { + let mut b = Bundle::decode(&bundle(ID, "1.0.0"), usize::MAX).unwrap(); + f(&mut b); + b.encode() + }; + let wasi = wat::parse_str(r#"(module (import "wasi_snapshot_preview1" "proc_exit" (func (param i32))) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap(); + for (bytes, why) in [ + (b"PK\x03\x04 a zip".to_vec(), "isn't"), + (vec![0u8; 70 << 10], "keeps plugins up to"), + (with(&|b| b.manifest = "{}".into()), "manifest"), + (with(&|b| b.manifest = manifest(ID, "1.0.0").replace("\"tiny.wasm\"", "\"tiny.wasm\", \"api\": 99")), "API"), + (with(&|b| b.manifest = manifest(ID, "latest")), "version"), + (with(&|b| b.wasm = b"not wasm".to_vec()), "WebAssembly"), + (with(&|b| b.wasm = wasi.clone()), "imports"), + (with(&|b| b.assets[0].name = "../../server.key".into()), "asset"), + (with(&|b| b.assets[0].name = "aux.png".into()), "asset"), + (with(&|b| b.assets[0].data = vec![0; 70 << 10]), "keeps plugins up to"), + ] { + let e = d.publish(&bytes).unwrap_err(); + assert!(e.contains(why), "{why}: {e}"); + } + assert!(d.entries().is_empty()); + // Only with the key. + assert!(matches!(d.handle(false, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Failed { reason } if reason.contains("key"))); + assert!(matches!(d.handle(false, PluginReq::Unpublish { id: ID.into(), version: None }), PluginReply::Failed { .. })); + assert!(matches!(d.handle(true, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Published(_))); + assert!(matches!(d.handle(false, PluginReq::List { query: "x".repeat(100_000) }), PluginReply::List(_))); + // Fetches share a budget: a flood is turned away, not queued. + let busy = (0..400).filter_map(|_| d.fetch(ID, None).err()).filter(|e| e.contains("busy")).count(); + assert!(busy > 0, "a flood of fetches hits the budget"); + drop(store); + // Caps: versions kept, bytes kept, off. + let dir = TempDir::new("plugins-caps"); + let size = bundle(ID, "1.0.0").len() as u64; + let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 2)).unwrap(); + store.directory().publish(&bundle(ID, "1.0.0")).unwrap(); + store.directory().publish(&bundle(ID, "1.0.1")).unwrap(); + assert!(store.directory().publish(&bundle(ID, "1.0.2")).unwrap_err().contains("at most 2")); + drop(store); + let dir = TempDir::new("plugins-full"); + let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_total_bytes = size + 10)).unwrap(); + store.directory().publish(&bundle(ID, "1.0.0")).unwrap(); + assert!(store.directory().publish(&bundle(ID, "1.0.1")).unwrap_err().contains("full")); + drop(store); + let dir = TempDir::new("plugins-off"); + let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 0)).unwrap(); + assert!( + matches!(store.directory().handle(true, PluginReq::List { query: String::new() }), PluginReply::Failed { reason } if reason.contains("off")) + ); + assert!(!dir.path().join(PLUGINS).exists()); +} + +fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: serde_json::Value) -> serde_json::Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +/// A plugin folder: manifest, module in a subfolder, an asset. +fn folder(root: &Path, version: &str) -> std::path::PathBuf { + let dir = root.join(format!("tiny-{version}")); + std::fs::create_dir_all(dir.join("assets")).unwrap(); + std::fs::write(dir.join("figcraft-plugin.json"), manifest(ID, version)).unwrap(); + std::fs::write(dir.join("tiny.wasm"), wasm()).unwrap(); + std::fs::write(dir.join("assets").join("icon.png"), [1, 2, 3]).unwrap(); + dir +} + +#[test] +fn publish_browse_install_update_and_remove_over_the_network() { + let dir = TempDir::new("plugins-net"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let addr = server.addr(); + let with_key = format!("ws://{addr}/?key={}", key(dir.path())); + let plain = format!("ws://{addr}/"); + // The publisher: the server's key. + let mut publisher = session("Ada"); + let e = publisher.execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.0"), "server": plain})).unwrap_err().to_string(); + assert!(e.contains("key"), "{e}"); + let r = ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.0"), "server": with_key})); + assert_eq!(r["published"]["version"], "1.0.0"); + let wrong = format!("ws://{addr}/?key=0123456789abcdef0123"); + assert!( + publisher + .execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.1"), "server": wrong})) + .unwrap_err() + .to_string() + .contains("wrong server key") + ); + // A user: browses without the key, installs only with confirmation, runs it. + let mut user = session("Bo"); + let listed = ex(&mut user, "plugin.directory.list", json!({"server": plain})); + let p = &listed["plugins"][0]; + assert_eq!((p["id"].as_str(), p["access"].as_str(), p["installed"].as_str()), (Some(ID), Some("write"), None)); + assert!(user.execute("plugin.directory.install", &json!({"id": ID})).is_err()); + let sha = p["sha256"].as_str().unwrap().to_string(); + let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha})); + assert_eq!((r["version"].as_str(), r["source"].as_str()), (Some("1.0.0"), Some(plain.as_str()))); + ex(&mut user, "plugin.run", json!({"id": ID})); + // An update appears and installs over it; browsing users can't unpublish. + ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.1"), "server": with_key})); + let listed = ex(&mut user, "plugin.directory.list", json!({})); + assert_eq!((listed["plugins"][0]["version"].as_str(), listed["plugins"][0]["update"].as_bool()), (Some("1.0.1"), Some(true))); + let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true})); + assert_eq!((r["version"].as_str(), r["replaced"].as_bool()), (Some("1.0.1"), Some(true))); + assert!(user.execute("plugin.directory.unpublish", &json!({"id": ID, "server": plain})).unwrap_err().to_string().contains("key")); + ex(&mut user, "plugin.remove", json!({"id": ID})); + let r = ex(&mut publisher, "plugin.directory.unpublish", json!({"id": ID, "server": with_key})); + assert_eq!(r["removed"], json!(["1.0.0", "1.0.1"])); + assert!(ex(&mut user, "plugin.directory.list", json!({}))["plugins"].as_array().unwrap().is_empty()); + server.stop(); +} + +#[test] +fn the_raw_protocol_refuses_wrong_keys_and_stray_messages() { + let dir = TempDir::new("plugins-raw"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let addr = server.addr(); + let open = |key: &str| ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: key.into(), client_version: String::new() }); + let answer = |raw: &mut Raw| match raw.next() { + ServerMsg::Plugins(r) => *r, + other => panic!("not a directory answer: {other:?}"), + }; + let mut raw = Raw::connect(addr); + raw.send(&open("not the key")); + assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("wrong server key"))); + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL + 1, key: String::new(), client_version: String::new() })); + assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("protocol"))); + let mut raw = Raw::connect(addr); + raw.send(&open("")); + assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: false }); + raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") })); + assert!(matches!(answer(&mut raw), PluginReply::Failed { reason } if reason.contains("key"))); + raw.send(&ClientMsg::Plugins(PluginReq::Fetch { id: "../../server.key".into(), version: Some("..".into()) })); + assert!(matches!(answer(&mut raw), PluginReply::Failed { .. })); + raw.send(&ClientMsg::Ping); + assert_eq!(raw.next(), ServerMsg::Pong); + raw.send(&ClientMsg::Admin(figcraft_collab::message::Admin::Files)); + assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("plugin directory"))); + let mut raw = Raw::connect(addr); + raw.send(&open(&key(dir.path()))); + assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: true }); + raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") })); + assert!(matches!(answer(&mut raw), PluginReply::Published(e) if e.id == ID)); + server.stop(); +} + +#[test] +fn the_command_line_lists_and_moderates() { + let dir = TempDir::new("plugins-cli"); + let run = |command: Command| cli::run(&Cli { config: config(dir.path()), command }); + assert!(run(Command::Plugins).unwrap().contains("No plugins yet")); + Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle(ID, "1.0.0")).unwrap(); + let table = run(Command::Plugins).unwrap(); + assert!(table.contains(ID) && table.contains("Ada Example") && table.contains("write"), "{table}"); + // Through the running server too. + let mut server = crate::start(&config(dir.path())).unwrap(); + assert!(run(Command::Plugins).unwrap().contains(ID)); + assert!(run(Command::RemovePlugin { id: ID.into(), version: Some("2.0.0".into()) }).is_err()); + assert!(run(Command::RemovePlugin { id: ID.into(), version: None }).unwrap().contains("Unpublished")); + server.stop(); + assert!(run(Command::Plugins).unwrap().contains("No plugins yet")); + let parsed = cli::parse(&["plugins".to_string(), "remove".into(), ID.into(), "--version".into(), "1.0.0".into()]).unwrap(); + assert_eq!(parsed.command, Command::RemovePlugin { id: ID.into(), version: Some("1.0.0".into()) }); + assert_eq!(cli::parse(&["--version".to_string()]).unwrap().command, Command::Version); +} diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index cc609c0f..a246cb98 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -10,6 +10,7 @@ pub mod cli; pub mod config; +pub mod directory; pub mod disk; pub mod store; @@ -73,6 +74,8 @@ pub fn start(cfg: &Config) -> Result { #[cfg(test)] mod cli_tests; #[cfg(test)] +mod directory_tests; +#[cfg(test)] mod library_tests; #[cfg(test)] mod server_tests; diff --git a/apps/figcraft-server/src/server_tests.rs b/apps/figcraft-server/src/server_tests.rs index 420e5bf8..3e13eace 100644 --- a/apps/figcraft-server/src/server_tests.rs +++ b/apps/figcraft-server/src/server_tests.rs @@ -200,6 +200,12 @@ fn fuzzed_frames_dont_hurt_the_server() { have: None, usage: Some(message::Usage { file: "Fuzz".into(), components: vec![(NodeId(1), 2)] }), }), + ClientMsg::OpenPlugins(message::OpenPlugins { protocol: message::PROTOCOL, key: String::new(), client_version: String::new() }), + ClientMsg::Plugins(message::PluginReq::List { query: "tiny".into() }), + ClientMsg::Plugins(message::PluginReq::Fetch { id: "org.example.tiny".into(), version: None }), + ClientMsg::Plugins(message::PluginReq::Publish { + bundle: figcraft_plugin::bundle::Bundle { manifest: "{}".into(), wasm: b"\0asm".to_vec(), assets: Vec::new() }.encode(), + }), ClientMsg::Ping, ] .iter() diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index f3cce2c7..896eaabe 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -11,6 +11,7 @@ //! /files//log.bin what changed since (see `disk`) //! /files//library.bin its team library, when one was published (a `.figcraft` archive) //! /files//usage.json how other files use that library, as their apps report it +//! /plugins/ the plugin directory (see `directory`) //! ``` //! //! An open file appends its server's journal to its log; the hub has the store make the appends @@ -26,13 +27,16 @@ use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, FileUsage, LibraryReply, LibraryReq, LinkInfo, NewLink, Usage}; +use figcraft_collab::message::{ + Access, Admin, AdminReply, FileInfo, FileUsage, LibraryReply, LibraryReq, LinkInfo, NewLink, PluginReply, PluginReq, Usage, +}; use figcraft_collab::{Change, Replica, Server, same_secret}; use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile}; use figcraft_collab_net::token; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::directory::{Directory, DirectoryConfig}; use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE}; pub const LOCK: &str = "LOCK"; @@ -55,6 +59,7 @@ pub struct StoreConfig { pub compact_after: usize, pub compact_bytes: u64, pub core: figcraft_collab::Limits, + pub directory: DirectoryConfig, } /// A file's description (`meta.json`). @@ -123,6 +128,8 @@ pub struct Store { files: BTreeMap, /// What was skipped at startup or found damaged since, for the operator. problems: Vec, + /// The plugin directory. + directory: Directory, _lock: File, } @@ -199,7 +206,8 @@ impl Store { let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; - let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), problems: Vec::new(), _lock: lock }; + let (directory, problems) = Directory::open(root, cfg.directory); + let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), problems, directory, _lock: lock }; store.scan(&dir); Ok(Some(store)) } @@ -266,6 +274,11 @@ impl Store { Ok(self.key.clone()) } + /// The plugin directory. + pub fn directory(&mut self) -> &mut Directory { + &mut self.directory + } + /// What was skipped or found damaged. pub fn problems(&self) -> &[String] { &self.problems @@ -736,6 +749,10 @@ impl Backend for Store { } } + fn plugins(&mut self, publisher: bool, req: PluginReq) -> PluginReply { + self.directory.handle(publisher, req) + } + fn closing(&self) -> Closing { Closing::Restarting } diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 6b2b89ad..63d109c5 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -132,6 +132,8 @@ fn main() -> eframe::Result { if let Some(prefs) = svc.prefs.clone() { session.prefs = prefs; } + // Plugins installed from a server's plugin directory live with the user's settings. + session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins")); // Invite links open this app (Windows registers per user at run time; installed builds by // default, never a build from source unless asked). session.live.links = figcraft_collab_net::links::system(); diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 881ed7e7..6ecd5fa9 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -4,8 +4,8 @@ //! //! What differs between the two lives behind a [`Backend`]: which hellos it admits and with what //! access, where a file that isn't open comes from, what is kept of the changes (a server appends -//! them to its log; the app keeps nothing), and the managing and team library connections a -//! server answers. The person hosting an app session takes part through the hub directly (no +//! them to its log; the app keeps nothing), and the managing, team library and plugin directory +//! connections a server answers. The person hosting an app session takes part through the hub directly (no //! socket): their presence and edits go in with [`Hosting::presence`] and [`Hosting::send`], what //! others do comes out through their [`Inbox`]. //! @@ -26,7 +26,8 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use figcraft_collab::message::{ - self, Access, Admin, AdminReply, ClientMsg, Hello, LibraryReply, LibraryReq, Manage, OpenLibrary, PROTOCOL, Presence, ServerMsg, + self, Access, Admin, AdminReply, ClientMsg, Hello, LibraryReply, LibraryReq, Manage, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply, PluginReq, + Presence, ServerMsg, }; use figcraft_collab::{Outgoing, Server}; use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize}; @@ -135,6 +136,12 @@ pub trait Backend: Send + 'static { LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() } } + /// A request on a plugin directory connection; `publisher`: it was opened with the right key + /// (checked by [`manage`](Self::manage)). + fn plugins(&mut self, _publisher: bool, _req: PluginReq) -> PluginReply { + PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } + } + /// What everyone is told when the hub stops. fn closing(&self) -> Closing; } @@ -146,7 +153,7 @@ enum Out { } enum HubIn { - /// A connection's first message (`Hello`, `Manage` or `OpenLibrary`). + /// A connection's first message (`Hello`, `Manage`, `OpenLibrary` or `OpenPlugins`). Open { conn: u64, first: ClientMsg, @@ -409,7 +416,7 @@ fn serve(stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, _ } }; match next_message(&mut ws, limits.max_hello) { - Ok(Some(first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) | ClientMsg::OpenLibrary(_)))) => { + Ok(Some(first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) | ClientMsg::OpenLibrary(_) | ClientMsg::OpenPlugins(_)))) => { ws.set_config(|c| *c = cfg); let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, first, out: out.clone(), sock: s }).is_ok()); if opened && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() { @@ -508,6 +515,8 @@ enum Kind { Managing, /// Using `file`'s team library through a link of `access`. Library { file: String, access: Access }, + /// Using the plugin directory; `publisher`: with the server's key. + Plugins { publisher: bool }, } /// A joined or managing connection. @@ -599,6 +608,7 @@ impl Hub { HubIn::Open { conn, first: ClientMsg::Hello(hello), out, sock } => self.join(conn, hello, out, sock), HubIn::Open { conn, first: ClientMsg::Manage(m), out, sock } => self.manage(conn, &m, out, sock), HubIn::Open { conn, first: ClientMsg::OpenLibrary(o), out, sock } => self.open_library(conn, o, out, sock), + HubIn::Open { conn, first: ClientMsg::OpenPlugins(o), out, sock } => self.open_plugins(conn, &o, out, sock), HubIn::Open { out, .. } => refuse(&out, "say hello first"), HubIn::Msg { conn, msg } => match self.conns.get(&conn).map(|c| &c.kind) { Some(Kind::Joined { file, id, .. }) => { @@ -619,6 +629,14 @@ impl Hub { ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only library requests are spoken on this connection"), }, + Some(Kind::Plugins { publisher }) => match msg { + ClientMsg::Plugins(req) => { + let reply = self.backend.plugins(*publisher, req); + self.answer(conn, &ServerMsg::Plugins(Box::new(reply))); + } + ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), + _ => self.kick(&[conn], "only plugin directory requests are spoken on this connection"), + }, None => {} }, HubIn::Gone { conn } => self.gone(conn), @@ -745,6 +763,19 @@ impl Hub { self.answer(conn, &ServerMsg::Library(Box::new(LibraryReply::Ready { access }))); } + /// A plugin directory connection: anyone may browse; a key, when given, must be the server's. + fn open_plugins(&mut self, conn: u64, o: &OpenPlugins, out: SyncSender, sock: TcpStream) { + if o.protocol != PROTOCOL { + return refuse(&out, &format!("protocol {} isn't supported (this server speaks {PROTOCOL})", o.protocol)); + } + let publisher = !o.key.is_empty(); + if publisher && let Err(why) = self.backend.manage(&o.key) { + return refuse(&out, &why); + } + self.conns.insert(conn, Conn { out, sock, kind: Kind::Plugins { publisher } }); + self.answer(conn, &ServerMsg::Plugins(Box::new(PluginReply::Ready { publisher }))); + } + fn admin(&mut self, conn: u64, req: Admin) { let rooms = &self.rooms; let open = |file: &str| { diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index b29086f4..a1c0c5ef 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -210,7 +210,7 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se // Checked again by the engine (the host is as untrusted as any peer). ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)), // Answers for managing and library connections: not this one's. - ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) => {} + ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {} } Taken::Continue } diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index e002a624..db83c5dd 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq}; +use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq}; use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo}; use crate::guest::{Guest, GuestConfig, Publish, upload_file}; @@ -173,6 +173,12 @@ impl LiveLink for NetLink { manage::library(link, req, &self.limits, CONNECT) } + fn plugins(&mut self, server: &str, req: PluginReq) -> Result { + let (server, query) = Endpoint::parse(server)?; + let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default(); + manage::plugins(&server, key.trim(), req, &self.limits, CONNECT) + } + fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) { if jobs.is_empty() || self.fetching.swap(true, Ordering::SeqCst) { return; diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 6d35b0cd..dfadb18d 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -1,11 +1,14 @@ //! Requests to a server outside a live session, each on a connection of its own answered in //! order: managing its files with the server's key ([`Manage`], [`request`]; the app publishes //! through it, and the server's command line uses it to reach a running server), and using a -//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]). +//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]), and its +//! plugin directory ([`OpenPlugins`], [`plugins`]: anyone browses, the key publishes). use std::time::Duration; -use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, PROTOCOL, ServerMsg}; +use figcraft_collab::message::{ + self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg, +}; use tungstenite::{Bytes, Message, WebSocket}; use crate::invite::{Endpoint, Invite}; @@ -59,6 +62,31 @@ pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duratio Ok(r) } +/// Send one request to the plugin directory of the server at `server`, with its `key` (empty: only +/// browsing); the reply. Fails when the server can't be reached, refuses the key or has no +/// directory (an older server); a request it can't do is a [`PluginReply::Failed`] reply. Never +/// echoes the key. +pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits, timeout: Duration) -> Result { + let pick = |m: ServerMsg| match m { + ServerMsg::Plugins(r) => Some(*r), + _ => None, + }; + let open = OpenPlugins { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() }; + let mut ws = connect(server, limits, timeout)?; + send(&mut ws, &ClientMsg::OpenPlugins(open))?; + match reply(&mut ws, limits, pick) { + Ok(PluginReply::Ready { .. }) => {} + // An older server can't read the request at all. + Err(e) if e.contains("couldn't be read") => return Err(format!("{} has no plugin directory (update the server)", server.display())), + Err(e) => return Err(e), + Ok(other) => return Err(format!("the server answered {other:?} instead of opening its plugin directory")), + } + send(&mut ws, &ClientMsg::Plugins(req))?; + let r = reply(&mut ws, limits, pick)?; + close(ws); + Ok(r) +} + /// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the /// server a while to keep). fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result { diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index f4d42fcf..6b94b14e 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -120,13 +120,18 @@ impl Bucket { /// Spend one token at `now`; false when there are none left. pub fn take(&mut self, now: Instant) -> bool { + self.take_n(now, 1.0) + } + + /// Spend `n` tokens at `now`; false (spending none) when there aren't that many. + pub fn take_n(&mut self, now: Instant, n: f64) -> bool { let dt = now.saturating_duration_since(self.at).as_secs_f64(); self.at = now; self.tokens = (self.tokens + dt * self.rate).min(self.burst); - if self.tokens < 1.0 { + if self.tokens < n { return false; } - self.tokens -= 1.0; + self.tokens -= n; true } } diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index c13de37c..2bfa337b 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -460,7 +460,9 @@ impl Client { self.rejoin(seq, catch_up, &processed, peers, recompute) } ServerMsg::Refused { reason } => Err(Error::Protocol(reason)), - ServerMsg::Admin(_) | ServerMsg::Library(_) => Err(Error::Protocol("an answer meant for a managing or library connection".into())), + ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => { + Err(Error::Protocol("an answer meant for a managing, library or plugin directory connection".into())) + } } } diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 57e68dc4..8d24580e 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -5,8 +5,8 @@ use figcraft_doc::{Document, NodeId, NodeKind}; use crate::change::{Change, Json, Prop}; use crate::message::{ Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileUsage, Hello, LibraryReply, LibraryReq, LinkInfo, Manage, - NewLink, OpenLibrary, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, - encode_json, + NewLink, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed, Resume, ServerMsg, Signal, Usage, + WIRE_VERSION, decode, decode_json, encode, encode_json, }; use crate::sim::Rng; use crate::{Error, Replica, diff}; @@ -63,11 +63,27 @@ fn samples() -> (Vec, Vec) { ClientMsg::Library(LibraryReq::Usage), ClientMsg::Signal(Signal::chat("Hi ☕")), ClientMsg::Signal(Signal::spotlight(true)), + ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: "0.3.1".into() }), + ClientMsg::Plugins(PluginReq::List { query: "grid".into() }), + ClientMsg::Plugins(PluginReq::Fetch { id: "org.example.p".into(), version: Some("1.0.0".into()) }), + ClientMsg::Plugins(PluginReq::Publish { bundle: vec![b'F', b'C'] }), + ClientMsg::Plugins(PluginReq::Unpublish { id: "org.example.p".into(), version: None }), ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 }; let link = LinkInfo { id: "l".into(), file: "f".into(), access: Access::Edit, label: String::new(), created: 6 }; let new_link = NewLink { link: link.clone(), token: "t".into() }; + let entry = PluginEntry { + id: "org.example.p".into(), + name: "Grille ☕".into(), + version: "1.2.0".into(), + access: "write".into(), + commands: vec!["node.*".into()], + bytes: 9, + sha256: "ab".into(), + versions: vec!["1.2.0".into(), "1.0.0".into()], + ..PluginEntry::default() + }; let server = vec![ ServerMsg::Welcome { session: 77, @@ -113,6 +129,12 @@ fn samples() -> (Vec, Vec) { ServerMsg::Library(Box::new(LibraryReply::Failed { reason: "no".into() })), ServerMsg::Signal { client_id: 4, signal: Signal::chat("Hi ☕") }, ServerMsg::Signal { client_id: 4, signal: Signal::ready() }, + ServerMsg::Plugins(Box::new(PluginReply::Ready { publisher: true })), + ServerMsg::Plugins(Box::new(PluginReply::List(vec![entry.clone()]))), + ServerMsg::Plugins(Box::new(PluginReply::Bundle { entry: entry.clone(), bundle: vec![1, 2] })), + ServerMsg::Plugins(Box::new(PluginReply::Published(entry))), + ServerMsg::Plugins(Box::new(PluginReply::Removed(vec!["1.0.0".into()]))), + ServerMsg::Plugins(Box::new(PluginReply::Failed { reason: "no".into() })), ]; (client, server) } diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 561e1445..671eec0a 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -373,6 +373,82 @@ pub enum LibraryReply { }, } +/// Opens a connection to a server's plugin directory (instead of [`Hello`]). Anyone who can reach +/// the server may list and fetch plugins; with the server's key (`key` not empty) the connection +/// may also publish and unpublish them. Answered with [`PluginReply::Ready`] or `Refused`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OpenPlugins { + pub protocol: u32, + /// The server's key, or empty to only browse. + pub key: String, + pub client_version: String, +} + +/// A plugin a directory keeps: what its manifest says (the author as they typed it) and the bundle +/// that holds it (its size and SHA-256, which a download must match). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PluginEntry { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub author: String, + /// The plugin API it was written for. + pub api: u32, + /// The permissions it asks for: `read` or `write` access to the document, and the commands + /// it may call (empty: any its access allows). + pub access: String, + pub commands: Vec, + /// It draws a widget. + pub widget: bool, + /// The bundle's size in bytes and its SHA-256 (hex). + pub bytes: u64, + pub sha256: String, + /// When it was published (seconds since 1970, UTC). + pub published: u64, + /// In listings: every version the directory keeps of it, newest first. + pub versions: Vec, +} + +/// A request on a plugin directory connection; each gets one [`PluginReply`], in order. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PluginReq { + /// The latest version of each plugin whose id, name, description or author contains `query` + /// (any case; empty: all). + List { query: String }, + /// A plugin's bundle: version `version`, or its latest. + Fetch { id: String, version: Option }, + /// Keep a bundle (`figcraft_plugin::bundle`) as a new version of its plugin. Needs the key. + Publish { bundle: Vec }, + /// Remove version `version` of a plugin, or all of them. Needs the key. + Unpublish { id: String, version: Option }, +} + +/// The answer to a [`PluginReq`] (the first one on a connection answers [`OpenPlugins`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PluginReply { + /// Requests may follow; `publisher`: the key was given, so publishing is allowed. + Ready { + publisher: bool, + }, + List(Vec), + /// A plugin's entry and its bundle's bytes (their SHA-256 is the entry's). + Bundle { + entry: PluginEntry, + bundle: Vec, + }, + Published(PluginEntry), + /// Unpublished: the versions removed. + Removed(Vec), + /// The request couldn't be done (the connection stays open). + Failed { + reason: String, + }, +} + /// Client → server. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -401,6 +477,10 @@ pub enum ClientMsg { Library(LibraryReq), /// Your cursor chat or spotlight (only to a server that sent [`Signal::READY`]). Signal(Signal), + /// Use the server's plugin directory (the first message on a connection, instead of `Hello`). + OpenPlugins(OpenPlugins), + /// A request on a plugin directory connection. + Plugins(PluginReq), } /// Server → client. @@ -454,6 +534,8 @@ pub enum ServerMsg { client_id: u32, signal: Signal, }, + /// The answer to a plugin directory connection's request. + Plugins(Box), } /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 723cfcfe..912b8788 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -383,7 +383,12 @@ impl Server { Vec::new() } ClientMsg::Ping => vec![Outgoing::To(from, ServerMsg::Pong)], - ClientMsg::Manage(_) | ClientMsg::Admin(_) | ClientMsg::OpenLibrary(_) | ClientMsg::Library(_) => { + ClientMsg::Manage(_) + | ClientMsg::Admin(_) + | ClientMsg::OpenLibrary(_) + | ClientMsg::Library(_) + | ClientMsg::OpenPlugins(_) + | ClientMsg::Plugins(_) => { vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })] } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 725dc7a2..073e0513 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -100,6 +100,7 @@ fn registry() -> &'static Registry { list.extend(crate::collab::specs()); list.extend(crate::branches::specs()); list.extend(crate::plugins::specs()); + list.extend(crate::plugin_directory::specs()); list.extend(crate::widgets::specs()); list.extend(crate::live::specs()); list.extend(crate::invite_links::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 71111cf9..cc3497fd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -35,6 +35,7 @@ pub mod library_swap; pub mod live; pub mod measure; pub mod params; +pub mod plugin_directory; pub mod plugins; pub mod proto_run; pub mod rename; @@ -544,6 +545,9 @@ pub struct Prefs { /// plugin. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub plugin_settings: BTreeMap>, + /// The plugin directory browsed last (`ws://host:port/`, never with a key). + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_directory: Option, /// Recently opened or saved files, newest first (File ▸ Open recent; at most /// [`cmd::file::MAX_RECENT`]). pub recent_files: Vec, @@ -566,6 +570,7 @@ impl Default for Prefs { invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), + plugin_directory: None, recent_files: Vec::new(), } } @@ -1341,6 +1346,8 @@ mod pdf_print_tests; #[cfg(test)] mod pixel_preview_tests; #[cfg(test)] +mod plugin_directory_tests; +#[cfg(test)] mod plugin_panels_tests; #[cfg(test)] mod plugins_tests; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 69aea5da..b68b0678 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -32,7 +32,7 @@ use std::sync::Arc; use figcraft_collab::Client; pub use figcraft_collab::message::Signal; -use figcraft_collab::message::{ClientMsg, EditingText, LibraryReply, LibraryReq, ServerMsg}; +use figcraft_collab::message::{ClientMsg, EditingText, LibraryReply, LibraryReq, PluginReply, PluginReq, ServerMsg}; pub use figcraft_collab::message::Access; use figcraft_doc::{Document, NodeId}; @@ -266,6 +266,12 @@ pub trait LiveLink: Send { /// Send library requests `(link, request)` in the background, in order; each answer arrives as /// [`Event::Library`]. Ignored while an earlier call's requests are still out. fn library_background(&mut self, _jobs: Vec<(String, LibraryReq)>) {} + /// One request to the plugin directory of the server at `server` (`ws://host:port/`, with + /// `?key=…` to publish); waits for the answer (a few seconds at most when it can't be + /// reached). Errors never echo the key. + fn plugins(&mut self, _server: &str, _req: PluginReq) -> std::result::Result { + Err(crate::plugin_directory::NO_DIRECTORY.into()) + } /// End the current session (stop hosting or leave). fn leave(&mut self); /// Your latest presence (the link throttles and coalesces). diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs new file mode 100644 index 00000000..a3f97a36 --- /dev/null +++ b/crates/engine/src/plugin_directory.rs @@ -0,0 +1,308 @@ +//! Plugins from a server's plugin directory (`figcraft-server`): listing it, installing and updating +//! plugins from it, and publishing and unpublishing them with the server's key. See +//! `docs/plugins.md` and `docs/server.md`. +//! +//! Everything a server sends is untrusted. An install needs the user's say-so (`confirm: true`, +//! which the app passes only from its confirmation step; nothing here opens a dialog), and the +//! download must match the SHA-256 the directory lists before anything else is read: then the +//! bundle is decoded within a size cap, its manifest and module are checked like an imported +//! plugin's (the module compiled, never run) and it runs in the same sandbox with its manifest's +//! permissions. Nothing here reaches the network except these commands. + +use std::cmp::Ordering; + +pub use figcraft_collab::message::PluginEntry; +use figcraft_collab::message::{PluginReply, PluginReq}; +pub use figcraft_plugin::bundle; +pub use figcraft_plugin::bundle::newer as newer_version; +use figcraft_plugin::bundle::{Asset, Bundle, MAX_ASSETS, check_asset_name, compare_versions, newer, sha256_hex}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{get, string}; +use crate::plugins::{InstalledPlugin, MANIFEST_FILE, Manifest, installed, register}; +use crate::{EngineError, Result, Session, bad, spec}; + +/// Why there's no plugin directory here (the browser build, sessions without the network). +pub const NO_DIRECTORY: &str = "plugin directories on a server need the desktop app, figcraft-cli or the MCP server"; +/// Largest bundle downloaded or published (a server's own cap is at most this). +pub const MAX_BUNDLE: usize = 32 << 20; +/// The module's file name in an installed plugin's folder, and the folder its assets go in. +const MODULE: &str = "module.wasm"; +const ASSETS: &str = "assets"; + +/// A directory as it was listed last. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Listing { + /// Its address (`ws://host:port/`, without a key). + pub server: String, + pub entries: Vec, +} + +/// A server address without its query (the key), as it's shown and remembered. +pub fn address(server: &str) -> String { + server.trim().split('?').next().unwrap_or_default().to_string() +} + +/// The installed version of plugin `id`, if it is installed. +pub fn installed_version<'a>(s: &'a Session, id: &str) -> Option<&'a str> { + s.prefs.plugins.iter().find(|p| p.manifest.id == id).map(|p| p.manifest.version.as_str()) +} + +/// Entry `e` with whether it's installed (its version) and whether that's older. +fn describe(s: &Session, e: &PluginEntry) -> Value { + let mut v = serde_json::to_value(e).unwrap_or_else(|_| json!({"id": e.id})); + let have = installed_version(s, &e.id); + v["installed"] = json!(have); + v["update"] = json!(have.is_some_and(|h| newer(&e.version, h))); + v +} + +/// `server`, or the directory browsed last. +fn server(s: &Session, cmd: &str, p: &Value) -> Result { + string(p, "server") + .filter(|v| !v.trim().is_empty()) + .or_else(|| s.prefs.plugin_directory.clone()) + .ok_or_else(|| bad(cmd, "`server` required: the server's address, like ws://host:47900/")) +} + +/// One request to the directory at `server`; a refusal is an error. +fn request(s: &mut Session, server: &str, req: PluginReq) -> Result { + let link = s.live.link().ok_or_else(|| EngineError::Other(NO_DIRECTORY.into()))?; + match link.plugins(server, req).map_err(EngineError::Other)? { + PluginReply::Failed { reason } => Err(EngineError::Other(reason)), + r => Ok(r), + } +} + +fn unexpected(cmd: &str) -> EngineError { + EngineError::Other(format!("{cmd}: the server answered something else")) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let cmd = "plugin.directory.list"; + let server = server(s, cmd, p)?; + let query = string(p, "query").unwrap_or_default(); + let PluginReply::List(entries) = request(s, &server, PluginReq::List { query })? else { return Err(unexpected(cmd)) }; + let server = address(&server); + let plugins: Vec = entries.iter().map(|e| describe(s, e)).collect(); + s.prefs.plugin_directory = Some(server.clone()); + s.plugins.directory = Some(Listing { server: server.clone(), entries }); + Ok(json!({"server": server, "plugins": plugins})) +} + +/// Download, check and install a plugin (see the module docs). +fn install(s: &mut Session, p: &Value) -> Result { + let cmd = "plugin.directory.install"; + let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required (see plugin.directory.list)"))?; + if get(p, "confirm") != Some(&Value::Bool(true)) { + return Err(bad( + cmd, + "installing a plugin runs its code on this machine: check the permissions it asks for (plugin.directory.list) and pass `confirm: true`", + )); + } + let server = server(s, cmd, p)?; + let version = string(p, "version"); + let PluginReply::Bundle { entry, bundle: bytes } = request(s, &server, PluginReq::Fetch { id: id.clone(), version: version.clone() })? else { + return Err(unexpected(cmd)); + }; + let fail = |why: &str| EngineError::Other(format!("{id} wasn't installed: {why}")); + if entry.id != id || version.as_deref().is_some_and(|v| compare_versions(&entry.version, v) != Some(Ordering::Equal)) { + return Err(fail("the server sent another plugin than the one asked for")); + } + if bytes.len() > MAX_BUNDLE { + return Err(fail("the download is too large")); + } + let sha = sha256_hex(&bytes); + if sha != entry.sha256 { + return Err(fail("the download doesn't match the SHA-256 the directory lists")); + } + if string(p, "sha256").is_some_and(|want| !want.trim().eq_ignore_ascii_case(&sha)) { + return Err(fail("the download isn't the version that was shown (its SHA-256 differs)")); + } + let bundle = Bundle::decode(&bytes, MAX_BUNDLE).map_err(|e| fail(&e))?; + let mut manifest = bundle.check().map_err(|e| fail(&e))?; + if manifest.id != entry.id || manifest.version != entry.version { + return Err(fail("its manifest doesn't match the directory's entry")); + } + let source = address(&server); + let path = save(s, &mut manifest, &bundle).map_err(|e| fail(&e))?; + let out = json!({ + "installed": manifest.id, + "name": manifest.name, + "version": manifest.version, + "permissions": manifest.permissions, + "sha256": sha, + "path": path, + "source": source, + }); + let replaced = register(s, InstalledPlugin { manifest, path, source: Some(source) }, bundle.wasm); + let mut out = out; + out["replaced"] = json!(replaced); + Ok(out) +} + +/// Keep an installed plugin's files in its own folder under the install folder (named by the hash +/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without +/// an install folder (the browser, the CLI and tests) the plugin lasts for the session. +fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result, String> { + let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) }; + #[cfg(not(target_arch = "wasm32"))] + { + use std::fs; + let hash = sha256_hex(m.id.as_bytes()); + let folder = root.join(hash.get(..32).unwrap_or(&hash)); + let partial = folder.with_extension("partial"); + let _ = fs::remove_dir_all(&partial); + m.main = MODULE.into(); + let write = || -> std::io::Result<()> { + fs::create_dir_all(&partial)?; + fs::write(partial.join(MODULE), &b.wasm)?; + if !b.assets.is_empty() { + fs::create_dir_all(partial.join(ASSETS))?; + for a in &b.assets { + // Checked plain names (`Bundle::check`): never a path. + fs::write(partial.join(ASSETS).join(&a.name), &a.data)?; + } + } + fs::write(partial.join(MANIFEST_FILE), serde_json::to_vec_pretty(&*m).map_err(std::io::Error::other)?) + }; + let done = write().and_then(|()| { + if folder.exists() { + fs::remove_dir_all(&folder)?; + } + fs::rename(&partial, &folder) + }); + if let Err(e) = done { + let _ = fs::remove_dir_all(&partial); + return Err(format!("couldn't write it to {}: {e}", root.display())); + } + Ok(Some(folder.join(MANIFEST_FILE).to_string_lossy().into_owned())) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (root, m, b); + Ok(None) + } +} + +/// Delete the folder of a plugin installed from a directory (only one inside the install folder). +pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) { + #[cfg(not(target_arch = "wasm32"))] + if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir) + && let Some(folder) = std::path::Path::new(path).parent() + && folder.parent() == Some(root.as_path()) + && let Err(e) = std::fs::remove_dir_all(folder) + { + log::warn!("plugins: couldn't delete {}: {e}", folder.display()); + } + #[cfg(target_arch = "wasm32")] + let _ = (s, p); +} + +/// The bundle of the plugin folder (or manifest) at `path`: its manifest as written, its module and +/// the files in its `assets` folder. +#[cfg(not(target_arch = "wasm32"))] +fn bundle_from_disk(cmd: &str, path: &str) -> Result { + use crate::plugins::{module_file, read_file}; + let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim())); + if file.is_dir() { + file.push(MANIFEST_FILE); + } + let text = + String::from_utf8(read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?).map_err(|_| bad(cmd, "the manifest isn't UTF-8 text"))?; + let m = Manifest::parse(&text).map_err(|e| bad(cmd, e))?; + let wasm = module_file(&file, &m)?; + let mut assets = Vec::new(); + let dir = file.parent().map(|d| d.join(ASSETS)); + if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) { + let mut names: Vec<(String, std::path::PathBuf)> = + entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect(); + names.sort(); + if names.len() > MAX_ASSETS { + return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files"))); + } + for (name, path) in names { + check_asset_name(&name).map_err(|e| bad(cmd, e))?; + assets.push(Asset { name, data: read_file(&path, MAX_BUNDLE)? }); + } + } + Ok(Bundle { manifest: text, wasm, assets }) +} + +#[cfg(target_arch = "wasm32")] +fn bundle_from_disk(cmd: &str, _path: &str) -> Result { + Err(bad(cmd, "there are no files in the browser: publish an installed plugin with `plugin`")) +} + +/// Publish a plugin folder (or an installed plugin) as a new version (needs the server's key). +fn publish(s: &mut Session, p: &Value) -> Result { + let cmd = "plugin.directory.publish"; + let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=… (`figcraft-server key` prints it)"))?; + let bundle = match (string(p, "path"), string(p, "plugin")) { + (Some(path), _) => bundle_from_disk(cmd, &path)?, + (None, Some(id)) => { + let ip = installed(s, cmd, &id)?.clone(); + match &ip.path { + Some(path) => bundle_from_disk(cmd, path)?, + None => { + let wasm = crate::plugins::module_of(s, &ip)?; + let manifest = serde_json::to_string(&ip.manifest).map_err(|e| EngineError::Other(e.to_string()))?; + Bundle { manifest, wasm: wasm.to_vec(), assets: Vec::new() } + } + } + } + (None, None) => return Err(bad(cmd, "pass `path` (a plugin folder or its figcraft-plugin.json) or `plugin` (an installed plugin's id)")), + }; + // Checked here too, for a clear message before anything is sent. + bundle.check().map_err(|e| bad(cmd, e))?; + let bytes = bundle.encode(); + if bytes.len() > MAX_BUNDLE { + return Err(bad(cmd, format!("the plugin is larger than {} MiB", MAX_BUNDLE >> 20))); + } + let PluginReply::Published(entry) = request(s, &server, PluginReq::Publish { bundle: bytes })? else { return Err(unexpected(cmd)) }; + Ok(json!({"published": entry, "server": address(&server)})) +} + +fn unpublish(s: &mut Session, p: &Value) -> Result { + let cmd = "plugin.directory.unpublish"; + let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?; + let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=…"))?; + let PluginReply::Removed(versions) = request(s, &server, PluginReq::Unpublish { id: id.clone(), version: string(p, "version") })? else { + return Err(unexpected(cmd)); + }; + Ok(json!({"id": id, "removed": versions})) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "plugin.directory.list", + "Browse plugin directory", + "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", + false, + list + ), + spec!( + "plugin.directory.install", + "Install plugin from directory", + "{id, version? (default: the latest), confirm: true (required: installing runs the plugin's code), server?, sha256? (the one shown)} downloads, checks the SHA-256 and installs or updates it → {installed, name, version, permissions, sha256, path, source, replaced}", + false, + install + ), + spec!( + "plugin.directory.publish", + "Publish plugin to directory", + "{path (a plugin folder or its figcraft-plugin.json) | plugin (an installed plugin's id), server (ws://host:port/?key=…)} → {published: entry, server}", + false, + publish + ), + spec!( + "plugin.directory.unpublish", + "Unpublish plugin from directory", + "{id, version? (default: every version), server (ws://host:port/?key=…)} → {id, removed: [versions]}", + false, + unpublish + ), + ] +} diff --git a/crates/engine/src/plugin_directory_tests.rs b/crates/engine/src/plugin_directory_tests.rs new file mode 100644 index 00000000..7cecc230 --- /dev/null +++ b/crates/engine/src/plugin_directory_tests.rs @@ -0,0 +1,254 @@ +//! Installing from and publishing to a plugin directory, against a scripted directory (the real +//! server, over the network, is tested in `figcraft-server`): nothing is fetched without +//! `confirm: true`, a download that doesn't match its SHA-256 or its entry is never installed, +//! installed plugins run in the sandbox, update and are removed with their folder, and publishing +//! sends a plugin folder as a checked bundle. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use figcraft_collab::message::{ClientMsg, PluginEntry, PluginReply, PluginReq}; +use figcraft_plugin::bundle::{Asset, Bundle, sha256_hex}; +use serde_json::{Value, json}; + +use crate::live::{Event, HostOptions, LiveLink, Presence, SessionInfo}; +use crate::{EngineError, Session}; + +/// A scripted directory: its bundles by entry, the requests it got, and damage to apply. +#[derive(Default)] +struct Dir { + plugins: Vec<(PluginEntry, Vec)>, + requests: Vec<(String, PluginReq)>, + /// Flip a byte of every bundle sent. + tamper: bool, + /// Send this id's bundle whatever was asked for. + swap: Option, +} + +struct Link(Arc>); + +impl LiveLink for Link { + fn host(&mut self, _: HostOptions) -> Result { + Err("no".into()) + } + fn join(&mut self, _: &str, _: &str) -> Result<(), String> { + Err("no".into()) + } + fn leave(&mut self) {} + fn presence(&mut self, _: &Presence) {} + fn send(&mut self, _: ClientMsg) {} + fn poll(&mut self) -> Vec { + Vec::new() + } + fn plugins(&mut self, server: &str, req: PluginReq) -> Result { + let mut d = self.0.lock().unwrap(); + d.requests.push((server.to_string(), req.clone())); + Ok(match req { + PluginReq::List { .. } => PluginReply::List(d.plugins.iter().map(|(e, _)| e.clone()).collect()), + PluginReq::Fetch { id, version } => { + let want = d.swap.clone().unwrap_or(id); + match d.plugins.iter().rev().find(|(e, _)| e.id == want && version.as_ref().is_none_or(|v| *v == e.version)) { + Some((entry, bytes)) => { + let mut bundle = bytes.clone(); + if d.tamper { + let last = bundle.len() - 1; + bundle[last] ^= 1; + } + PluginReply::Bundle { entry: entry.clone(), bundle } + } + None => PluginReply::Failed { reason: "no such plugin".into() }, + } + } + PluginReq::Publish { bundle } => { + let b = Bundle::decode(&bundle, usize::MAX).map_err(|e| e.to_string())?; + let m = b.check()?; + let e = entry(&m.id, &m.version, &bundle); + d.plugins.push((e.clone(), bundle)); + PluginReply::Published(e) + } + PluginReq::Unpublish { id, .. } => { + d.plugins.retain(|(e, _)| e.id != id); + PluginReply::Removed(vec!["all".into()]) + } + }) + } +} + +/// A module that does nothing and succeeds. +fn wasm() -> Vec { + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap() +} + +fn manifest(id: &str, version: &str) -> String { + json!({"id": id, "name": "Tiny", "version": version, "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "read"}}).to_string() +} + +fn bundle(id: &str, version: &str) -> Vec { + Bundle { manifest: manifest(id, version), wasm: wasm(), assets: vec![Asset { name: "LICENSE".into(), data: b"MIT".to_vec() }] }.encode() +} + +fn entry(id: &str, version: &str, bytes: &[u8]) -> PluginEntry { + PluginEntry { + id: id.into(), + name: "Tiny".into(), + version: version.into(), + author: "Ada".into(), + access: "read".into(), + bytes: bytes.len() as u64, + sha256: sha256_hex(bytes), + ..PluginEntry::default() + } +} + +fn offer(d: &Arc>, id: &str, version: &str) { + let b = bundle(id, version); + d.lock().unwrap().plugins.push((entry(id, version, &b), b)); +} + +fn session() -> (Session, Arc>) { + let mut s = Session::new(); + let d = Arc::new(Mutex::new(Dir::default())); + s.live.install(Box::new(Link(d.clone()))); + (s, d) +} + +fn fetches(d: &Arc>) -> usize { + d.lock().unwrap().requests.iter().filter(|(_, r)| matches!(r, PluginReq::Fetch { .. })).count() +} + +fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("figcraft-plugdir-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +const ID: &str = "org.example.tiny"; + +#[test] +fn listing_remembers_the_server_without_its_key() { + let (mut s, d) = session(); + offer(&d, ID, "1.0.0"); + let r = s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/?key=secret"})).unwrap(); + assert_eq!(r["server"], "ws://127.0.0.1:9/"); + assert_eq!(r["plugins"][0]["id"], ID); + assert_eq!(r["plugins"][0]["installed"], Value::Null); + assert_eq!(s.prefs.plugin_directory.as_deref(), Some("ws://127.0.0.1:9/")); + assert!(!serde_json::to_string(&s.prefs).unwrap().contains("secret"), "the key is never kept"); + // The server browsed last is the default. + s.execute("plugin.directory.list", &json!({})).unwrap(); + assert_eq!(d.lock().unwrap().requests.last().unwrap().0, "ws://127.0.0.1:9/"); + // Without a link (the browser, a bare session) it's a clear error. + let e = Session::new().execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap_err().to_string(); + assert!(e.contains("desktop app"), "{e}"); +} + +#[test] +fn installing_needs_confirm_and_a_matching_download() { + let (mut s, d) = session(); + offer(&d, ID, "1.0.0"); + s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap(); + for confirm in [json!(null), json!(false), json!("true"), json!(1)] { + let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": confirm})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }) && e.to_string().contains("confirm"), "{e}"); + } + assert_eq!(fetches(&d), 0, "nothing is downloaded before the user confirms"); + // A damaged download, or one that isn't what was shown, is never installed. + d.lock().unwrap().tamper = true; + let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string(); + assert!(e.contains("SHA-256"), "{e}"); + d.lock().unwrap().tamper = false; + let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true, "sha256": "00ff"})).unwrap_err().to_string(); + assert!(e.contains("shown"), "{e}"); + offer(&d, "org.example.other", "1.0.0"); + d.lock().unwrap().swap = Some("org.example.other".into()); + let e = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string(); + assert!(e.contains("another plugin"), "{e}"); + d.lock().unwrap().swap = None; + // A bundle whose manifest disagrees with its entry. + let b = bundle("org.example.liar", "9.9.9"); + d.lock().unwrap().plugins.push((entry(ID, "2.0.0", &b), b)); + let e = s.execute("plugin.directory.install", &json!({"id": ID, "version": "2.0.0", "confirm": true})).unwrap_err().to_string(); + assert!(e.contains("doesn't match"), "{e}"); + assert!(s.prefs.plugins.is_empty(), "nothing got installed"); + // The real thing: installed for the session (no install folder), and it runs. + let r = s.execute("plugin.directory.install", &json!({"id": ID, "version": "1.0.0", "confirm": true})).unwrap(); + assert_eq!((r["version"].as_str(), r["path"].as_str(), r["replaced"].as_bool()), (Some("1.0.0"), None, Some(false))); + assert_eq!(s.prefs.plugins[0].source.as_deref(), Some("ws://127.0.0.1:9/")); + s.execute("plugin.run", &json!({"id": ID})).unwrap(); + let listed = s.execute("plugin.directory.list", &json!({})).unwrap(); + let mine = listed["plugins"].as_array().unwrap().iter().find(|p| p["version"] == "1.0.0").unwrap().clone(); + assert_eq!((mine["installed"].as_str(), mine["update"].as_bool()), (Some("1.0.0"), Some(false))); +} + +#[test] +fn installed_plugins_live_in_their_folder_update_and_go() { + let (mut s, d) = session(); + let root = temp("install"); + s.plugins.install_dir = Some(root.clone()); + offer(&d, ID, "1.0.0"); + let r = s.execute("plugin.directory.install", &json!({"id": ID, "server": "ws://127.0.0.1:9/", "confirm": true})).unwrap(); + let manifest = PathBuf::from(r["path"].as_str().unwrap()); + let folder = manifest.parent().unwrap().to_path_buf(); + assert_eq!(folder.parent().unwrap(), root.as_path()); + assert!(!folder.file_name().unwrap().to_string_lossy().contains("example"), "the folder is named by a hash, not the id"); + assert_eq!(std::fs::read(folder.join("module.wasm")).unwrap(), wasm()); + assert_eq!(std::fs::read(folder.join("assets").join("LICENSE")).unwrap(), b"MIT"); + assert!(std::fs::read_to_string(&manifest).unwrap().contains("module.wasm")); + s.execute("plugin.run", &json!({"id": ID})).unwrap(); + // A newer version is an update; the same folder holds it. + offer(&d, ID, "1.1.0"); + let listed = s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap(); + assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["version"] == "1.1.0" && p["update"] == true)); + let r = s.execute("plugin.directory.install", &json!({"id": ID, "version": "1.1.0", "confirm": true})).unwrap(); + assert_eq!((r["replaced"].as_bool(), r["path"].as_str()), (Some(true), manifest.to_str())); + assert_eq!(s.prefs.plugins.len(), 1); + assert_eq!(s.prefs.plugins[0].manifest.version, "1.1.0"); + s.execute("plugin.run", &json!({"id": ID})).unwrap(); + // Removing deletes its folder (and only its folder). + s.execute("plugin.remove", &json!({"id": ID})).unwrap(); + assert!(!folder.exists() && root.exists()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn publishing_sends_a_checked_folder() { + let (mut s, d) = session(); + let dir = temp("publish"); + std::fs::create_dir_all(dir.join("build")).unwrap(); + std::fs::create_dir_all(dir.join("assets")).unwrap(); + std::fs::write(dir.join("build").join("tiny.wasm"), wasm()).unwrap(); + let text = json!({"id": ID, "name": "Tiny", "version": "1.0.0", "main": "build/tiny.wasm"}).to_string(); + std::fs::write(dir.join("figcraft-plugin.json"), &text).unwrap(); + std::fs::write(dir.join("assets").join("icon.png"), [1, 2, 3]).unwrap(); + let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("server"), "{e}"); + let r = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://127.0.0.1:9/?key=k"})).unwrap(); + assert_eq!((r["published"]["version"].as_str(), r["server"].as_str()), (Some("1.0.0"), Some("ws://127.0.0.1:9/"))); + let sent = d.lock().unwrap().plugins.last().unwrap().1.clone(); + let b = Bundle::decode(&sent, usize::MAX).unwrap(); + assert_eq!((b.manifest.as_str(), b.wasm.clone()), (text.as_str(), wasm())); + assert_eq!(b.assets, vec![Asset { name: "icon.png".into(), data: vec![1, 2, 3] }]); + // Refused before anything is sent: a hidden asset, a version a directory can't compare. + let count = d.lock().unwrap().requests.len(); + std::fs::write(dir.join("assets").join(".secret"), b"x").unwrap(); + assert!( + s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})) + .unwrap_err() + .to_string() + .contains("asset") + ); + std::fs::remove_file(dir.join("assets").join(".secret")).unwrap(); + std::fs::write(dir.join("figcraft-plugin.json"), text.replace("1.0.0", "next")).unwrap(); + assert!( + s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})) + .unwrap_err() + .to_string() + .contains("version") + ); + assert_eq!(d.lock().unwrap().requests.len(), count); + // Unpublishing names it. + let r = s.execute("plugin.directory.unpublish", &json!({"id": ID, "server": "ws://127.0.0.1:9/?key=k"})).unwrap(); + assert_eq!(r["id"], ID); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index 2f729bfa..f1427ed3 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -3,7 +3,8 @@ //! commands a plugin may call (its manifest's permissions, and never files, other documents, the //! undo history, dialogs or plugins), one undo step per run or panel callback, the panels plugins //! keep open, their settings and the installed-plugin list kept in the preferences. Widgets (plugin -//! layers on the canvas) are in [`crate::widgets`]. See `docs/plugins.md`. +//! layers on the canvas) are in [`crate::widgets`], installing from a server's plugin directory in +//! [`crate::plugin_directory`]. See `docs/plugins.md`. use std::collections::HashMap; use std::sync::Arc; @@ -30,6 +31,9 @@ pub struct InstalledPlugin { /// session only and isn't saved with the preferences). #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, + /// The plugin directory it was installed from (`ws://host:port/`), if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, } /// Saves only the plugins imported from a manifest file (the others can't be loaded again). @@ -46,6 +50,11 @@ pub struct Runtime { pub last: Option<(String, String, Value)>, /// The open plugin panels, one per plugin, in the order they opened. pub panels: Vec, + /// Where plugins installed from a directory are kept (the app sets a folder of the user's; + /// without one, such plugins last for the session only). + pub install_dir: Option, + /// The plugin directory listed last (Plugins ▸ Manage plugins ▸ Browse shows it). + pub directory: Option, } impl Runtime { @@ -219,7 +228,7 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I /// Read a file of at most `max` bytes. #[cfg(not(target_arch = "wasm32"))] -fn read_file(path: &std::path::Path, max: usize) -> Result> { +pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result> { let shown = path.display(); let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; if !meta.is_file() || meta.len() > max as u64 { @@ -250,7 +259,7 @@ fn load_from_disk(cmd: &str, _path: &str) -> Result<(Manifest, String, Vec)> /// The module a manifest at `manifest_file` names. #[cfg(not(target_arch = "wasm32"))] -fn module_file(manifest_file: &std::path::Path, m: &Manifest) -> Result> { +pub(crate) fn module_file(manifest_file: &std::path::Path, m: &Manifest) -> Result> { let dir = manifest_file.parent().unwrap_or(std::path::Path::new(".")); read_file(&dir.join(&m.main), Limits::default().module_bytes) } @@ -287,6 +296,7 @@ pub(crate) fn plugin_error(name: &str, e: PluginError) -> EngineError { fn describe(s: &Session, p: &InstalledPlugin) -> Value { let mut v = serde_json::to_value(&p.manifest).unwrap_or_else(|_| json!({"id": p.manifest.id})); v["path"] = json!(p.path); + v["source"] = json!(p.source); v["loaded"] = json!(p.path.is_some() || s.plugins.modules.contains_key(&p.manifest.id)); v } @@ -321,16 +331,24 @@ fn import(s: &mut Session, p: &Value) -> Result { (None, None) => return Err(bad(cmd, "pass `path` (a figcraft-plugin.json or its folder), or `manifest` and `wasm`")), }; figcraft_plugin::check_module(&wasm, &Limits::default()).map_err(|e| plugin_error(&manifest.name, e))?; - let id = manifest.id.clone(); - if path.is_none() { + let mut out = describe(s, &InstalledPlugin { manifest: manifest.clone(), path: path.clone(), source: None }); + out["replaced"] = json!(register(s, InstalledPlugin { manifest, path, source: None }, wasm)); + out["loaded"] = json!(true); + Ok(out) +} + +/// Install `entry` (its module `wasm` is kept in the session when it has no manifest file), +/// replacing a plugin with the same id (whose panel closes); whether one was replaced. +pub(crate) fn register(s: &mut Session, entry: InstalledPlugin, wasm: Vec) -> bool { + let id = entry.manifest.id.clone(); + if entry.path.is_none() { s.plugins.modules.insert(id.clone(), Arc::new(wasm)); } else { s.plugins.modules.remove(&id); } - let entry = InstalledPlugin { manifest, path }; - let mut out = describe(s, &entry); + close_panel(s, &id); let list = &mut s.prefs.plugins; - let replaced = match list.iter_mut().find(|x| x.manifest.id == id) { + match list.iter_mut().find(|x| x.manifest.id == id) { Some(slot) => { *slot = entry; true @@ -339,15 +357,14 @@ fn import(s: &mut Session, p: &Value) -> Result { list.push(entry); false } - }; - out["replaced"] = json!(replaced); - Ok(out) + } } fn remove(s: &mut Session, p: &Value) -> Result { let cmd = "plugin.remove"; let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?; - installed(s, cmd, &id)?; + let p = installed(s, cmd, &id)?.clone(); + crate::plugin_directory::delete_installed(s, &p); s.prefs.plugins.retain(|x| x.manifest.id != id); s.prefs.plugin_settings.remove(&id); s.plugins.modules.remove(&id); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 980d0106..f10b22aa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -263,6 +263,9 @@ fn every_command_survives_junk_params() { json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), + // Plugin directories: ids, versions, servers and confirmations as junk. + json!({"id": "../../x", "version": {"x": 1}, "server": "ws://\u{0}/?key=", "confirm": "yes", "path": [5], "plugin": [1], "query": 7, "sha256": 3}), + json!({"id": "org.example.x", "version": "1.0.0", "server": "wss://[::1", "confirm": true, "plugin": "nope"}), ]; for spec in all_commands() { if matches!( diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index db990802..957803dc 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -12,6 +12,8 @@ workspace = true figcraft-color = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# A bundle's SHA-256 (plugin directories list it; the app checks downloads against it). +sha2 = { workspace = true } thiserror = { workspace = true } wasmi = { workspace = true } diff --git a/crates/plugin/src/bundle.rs b/crates/plugin/src/bundle.rs new file mode 100644 index 00000000..3bd519e6 --- /dev/null +++ b/crates/plugin/src/bundle.rs @@ -0,0 +1,206 @@ +//! Plugin bundles: a plugin as a plugin directory keeps and serves it (its manifest, its module and +//! a few optional asset files) in one uncompressed binary form, so nothing about it can expand +//! beyond the bytes that arrived. The SHA-256 of those bytes names the bundle: a server lists it +//! with every entry and the app checks a download against it before reading anything else. +//! +//! ```text +//! "FCPLUGB1" magic +//! u32 LE length, manifest JSON (UTF-8, `figcraft-plugin.json`) +//! u32 LE length, WebAssembly module +//! u32 LE asset count, then per asset: u32 LE length, name (UTF-8), u32 LE length, bytes +//! ``` +//! +//! Everything in a bundle is untrusted: [`Bundle::decode`] reads it without allocating more than +//! its own size, and [`Bundle::check`] validates the manifest (and the API version it asks for), its +//! version, the module (compiled, never run) and the asset names before anyone keeps or installs it. + +use std::cmp::Ordering; + +use sha2::{Digest, Sha256}; + +use crate::manifest::Manifest; +use crate::sandbox::{Limits, check_module}; + +/// The first bytes of every bundle. +pub const MAGIC: &[u8; 8] = b"FCPLUGB1"; +/// Most asset files in a bundle, and their longest name. +pub const MAX_ASSETS: usize = 32; +const MAX_ASSET_NAME: usize = 64; +/// Longest version (`1.2.3`), and most of its numbers. +const MAX_VERSION: usize = 32; +const MAX_VERSION_PARTS: usize = 4; + +/// A plugin as a directory keeps it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Bundle { + /// The manifest's JSON text. + pub manifest: String, + /// The module the manifest's `main` names. + pub wasm: Vec, + /// Files that travel with the plugin (an icon, a licence), installed in its `assets` folder. + pub assets: Vec, +} + +/// A file in a bundle: a plain file name (no folders) and its bytes. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Asset { + pub name: String, + pub data: Vec, +} + +/// SHA-256 of `bytes`, lowercase hex. +pub fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// A directory version: 1–4 whole numbers separated by dots (`1.2.0`). +pub fn parse_version(s: &str) -> Option> { + if s.is_empty() || s.len() > MAX_VERSION { + return None; + } + let parts: Vec = + s.split('.').map(|p| if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) { None } else { p.parse().ok() }).collect::>()?; + (parts.len() <= MAX_VERSION_PARTS).then_some(parts) +} + +/// How version `a` compares with `b` (missing numbers count as 0: `1.2` = `1.2.0`); `None` when +/// either isn't a directory version. +pub fn compare_versions(a: &str, b: &str) -> Option { + let (a, b) = (parse_version(a)?, parse_version(b)?); + let n = a.len().max(b.len()); + let at = |v: &[u64], i: usize| v.get(i).copied().unwrap_or(0); + Some((0..n).map(|i| at(&a, i).cmp(&at(&b, i))).find(|o| o.is_ne()).unwrap_or(Ordering::Equal)) +} + +/// Version `candidate` is newer than `installed` (false when either isn't a directory version). +pub fn newer(candidate: &str, installed: &str) -> bool { + compare_versions(candidate, installed) == Some(Ordering::Greater) +} + +/// An asset's name: 1–64 letters, digits, `.`, `-` or `_`, not starting with a dot, and not a name +/// Windows reserves for a device (`con`, `nul.txt`…), so it is a plain file name everywhere. +pub fn check_asset_name(name: &str) -> Result<(), String> { + let plain = !name.is_empty() + && name.len() <= MAX_ASSET_NAME + && !name.starts_with('.') + && !name.ends_with('.') + && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')); + let stem = name.split('.').next().unwrap_or_default().to_ascii_lowercase(); + let device = matches!(stem.as_str(), "con" | "prn" | "aux" | "nul") + || ((stem.starts_with("com") || stem.starts_with("lpt")) && stem.len() == 4 && stem.as_bytes().get(3).is_some_and(u8::is_ascii_digit)); + if !plain || device { + return Err(format!( + "asset `{}` must be a plain file name: 1–{MAX_ASSET_NAME} letters, digits, `.`, `-` or `_`", + name.chars().take(40).collect::() + )); + } + Ok(()) +} + +/// Reads length-prefixed parts of untrusted bytes. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8], String> { + if n > self.0.len() { + return Err("the bundle is cut short".into()); + } + let (head, rest) = self.0.split_at(n); + self.0 = rest; + Ok(head) + } + + fn u32(&mut self) -> Result { + let b: [u8; 4] = self.take(4)?.try_into().map_err(|_| "the bundle is cut short".to_string())?; + usize::try_from(u32::from_le_bytes(b)).map_err(|_| "the bundle is damaged".to_string()) + } + + /// A length, then that many bytes (the length can't exceed what's left, so nothing + /// allocates beyond the input). + fn part(&mut self) -> Result<&'a [u8], String> { + let n = self.u32()?; + self.take(n) + } + + fn text(&mut self, what: &str) -> Result { + String::from_utf8(self.part()?.to_vec()).map_err(|_| format!("the bundle's {what} isn't UTF-8 text")) + } +} + +fn push_part(out: &mut Vec, bytes: &[u8]) { + // Parts are capped far below 4 GiB by the limits checked before encoding and after decoding. + out.extend(u32::try_from(bytes.len()).unwrap_or(u32::MAX).to_le_bytes()); + out.extend(bytes); +} + +impl Bundle { + /// Its binary form (see the module docs). + pub fn encode(&self) -> Vec { + let size = + MAGIC.len() + 12 + self.manifest.len() + self.wasm.len() + self.assets.iter().map(|a| 8 + a.name.len() + a.data.len()).sum::(); + let mut out = Vec::with_capacity(size); + out.extend(MAGIC); + push_part(&mut out, self.manifest.as_bytes()); + push_part(&mut out, &self.wasm); + out.extend(u32::try_from(self.assets.len()).unwrap_or(u32::MAX).to_le_bytes()); + for a in &self.assets { + push_part(&mut out, a.name.as_bytes()); + push_part(&mut out, &a.data); + } + out + } + + /// Read a bundle of at most `max` bytes (untrusted input: never panics, allocates at most its + /// size, refuses trailing bytes). + pub fn decode(bytes: &[u8], max: usize) -> Result { + if bytes.len() > max { + return Err(format!("the bundle is {} KB, over the {} KB limit", bytes.len() / 1024, max / 1024)); + } + let mut r = Reader(bytes); + if r.take(MAGIC.len()).ok() != Some(MAGIC.as_slice()) { + return Err("that isn't a FigCraft plugin bundle".into()); + } + let manifest = r.text("manifest")?; + let wasm = r.part()?.to_vec(); + let count = r.u32()?; + if count > MAX_ASSETS { + return Err(format!("the bundle has more than {MAX_ASSETS} assets")); + } + let mut assets = Vec::with_capacity(count); + for _ in 0..count { + let name = r.text("asset name")?; + let data = r.part()?.to_vec(); + assets.push(Asset { name, data }); + } + if !r.0.is_empty() { + return Err(format!("{} bytes after the bundle", r.0.len())); + } + Ok(Bundle { manifest, wasm, assets }) + } + + /// Validate everything before the bundle is kept or installed: the manifest (with the plugin + /// API version it asks for), a directory version (`1.2.0`), the module (compiled and checked + /// for what it imports, never run) and the asset names (plain, unique ignoring case). The + /// manifest. + pub fn check(&self) -> Result { + let m = Manifest::parse(&self.manifest)?; + if parse_version(&m.version).is_none() { + return Err(format!( + "a plugin in a directory needs a version of 1–{MAX_VERSION_PARTS} numbers like 1.0.0 in its manifest (got `{}`)", + m.version.chars().take(40).collect::() + )); + } + check_module(&self.wasm, &Limits::default()).map_err(|e| format!("{}: {e}", m.name))?; + if self.assets.len() > MAX_ASSETS { + return Err(format!("more than {MAX_ASSETS} assets")); + } + let mut seen = std::collections::BTreeSet::new(); + for a in &self.assets { + check_asset_name(&a.name)?; + if !seen.insert(a.name.to_ascii_lowercase()) { + return Err(format!("two assets are called `{}`", a.name)); + } + } + Ok(m) + } +} diff --git a/crates/plugin/src/bundle_tests.rs b/crates/plugin/src/bundle_tests.rs new file mode 100644 index 00000000..8bb9d220 --- /dev/null +++ b/crates/plugin/src/bundle_tests.rs @@ -0,0 +1,102 @@ +//! Plugin bundles: the binary form, its validation and directory versions. + +use std::cmp::Ordering; + +use crate::bundle::{Asset, Bundle, MAGIC, MAX_ASSETS, check_asset_name, compare_versions, newer, parse_version, sha256_hex}; + +fn wasm() -> Vec { + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap() +} + +fn manifest(version: &str, api: u32) -> String { + format!(r#"{{"id": "org.example.b", "name": "B", "version": "{version}", "author": " Ada ", "main": "b.wasm", "api": {api}}}"#) +} + +fn bundle() -> Bundle { + Bundle { manifest: manifest("1.2.0", 1), wasm: wasm(), assets: vec![Asset { name: "icon.png".into(), data: vec![1, 2, 3] }] } +} + +#[test] +fn round_trips_and_checks() { + let b = bundle(); + let bytes = b.encode(); + assert!(bytes.starts_with(MAGIC)); + assert_eq!(Bundle::decode(&bytes, bytes.len()).unwrap(), b); + assert_eq!(sha256_hex(&bytes), sha256_hex(&b.encode()), "the same bundle hashes the same"); + assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + let m = b.check().unwrap(); + assert_eq!((m.id.as_str(), m.version.as_str(), m.author.as_str()), ("org.example.b", "1.2.0", "Ada")); +} + +#[test] +fn decoding_refuses_damage() { + let bytes = bundle().encode(); + assert!(Bundle::decode(&bytes, bytes.len() - 1).unwrap_err().contains("limit")); + for n in 0..bytes.len() { + assert!(Bundle::decode(&bytes[..n], usize::MAX).is_err(), "cut at {n}"); + } + let mut extra = bytes.clone(); + extra.push(0); + assert!(Bundle::decode(&extra, usize::MAX).unwrap_err().contains("after")); + let mut magic = bytes.clone(); + magic[0] = b'X'; + assert!(Bundle::decode(&magic, usize::MAX).unwrap_err().contains("isn't")); + // A length larger than what follows never allocates it. + let mut huge = MAGIC.to_vec(); + huge.extend(u32::MAX.to_le_bytes()); + assert!(Bundle::decode(&huge, usize::MAX).unwrap_err().contains("cut short")); + // Too many assets. + let mut many = MAGIC.to_vec(); + for part in [&b"{}"[..], &b"\0asm"[..]] { + many.extend((part.len() as u32).to_le_bytes()); + many.extend(part); + } + many.extend((MAX_ASSETS as u32 + 1).to_le_bytes()); + assert!(Bundle::decode(&many, usize::MAX).unwrap_err().contains("assets")); + // Random damage never panics. + let mut seed = 7u64; + for _ in 0..2000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let mut b = bytes.clone(); + let i = (seed >> 33) as usize % b.len(); + b[i] = (seed >> 13) as u8; + if seed.is_multiple_of(3) { + b.truncate(i); + } + if let Ok(x) = Bundle::decode(&b, usize::MAX) { + let _ = x.check(); + } + } +} + +#[test] +fn checking_refuses_bad_plugins() { + let with = |f: &dyn Fn(&mut Bundle)| { + let mut b = bundle(); + f(&mut b); + b.check().unwrap_err() + }; + assert!(with(&|b| b.manifest = "{".into()).contains("manifest")); + assert!(with(&|b| b.manifest = manifest("1.0.0", 99)).contains("API 99")); + for v in ["", "1.0-beta", "v1", "1..2", "1.2.3.4.5", "99999999999999999999"] { + assert!(with(&|b| b.manifest = manifest(v, 1)).contains("version"), "{v}"); + } + assert!(with(&|b| b.wasm = b"MZ not wasm".to_vec()).contains("WebAssembly")); + let wasi = wat::parse_str(r#"(module (import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32))) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap(); + assert!(with(&|b| b.wasm = wasi.clone()).contains("imports")); + for name in ["../x", "a/b", "a\\b", ".hidden", "con", "NUL.txt", "com1.png", "x.", "", "é.png"] { + assert!(check_asset_name(name).is_err(), "{name}"); + assert!(with(&|b| b.assets[0].name = name.into()).contains("asset"), "{name}"); + } + assert!(check_asset_name("console.png").is_ok() && check_asset_name("LICENSE").is_ok() && check_asset_name("com10.txt").is_ok()); + assert!(with(&|b| b.assets.push(Asset { name: "ICON.png".into(), data: vec![] })).contains("two assets")); +} + +#[test] +fn versions_compare_by_number() { + assert_eq!(parse_version("1.10.0"), Some(vec![1, 10, 0])); + assert_eq!(compare_versions("1.10.0", "1.9.9"), Some(Ordering::Greater)); + assert_eq!(compare_versions("1.2", "1.2.0"), Some(Ordering::Equal)); + assert_eq!(compare_versions("1.2", "beta"), None); + assert!(newer("2.0.0", "1.9") && !newer("1.0.0", "1.0.0") && !newer("1.0.0", "") && !newer("x", "1.0")); +} diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index a4bbbb67..582b0e64 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -4,6 +4,7 @@ //! callback, where settings are kept); this crate only runs modules safely: a metered interpreter //! with a memory cap, no WASI and a handful of host functions. See `docs/plugins.md`. +pub mod bundle; pub mod manifest; mod sandbox; pub mod widget; @@ -12,6 +13,8 @@ pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, P pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run}; pub use widget::{WidgetKind, WidgetNode, WidgetRender}; +#[cfg(test)] +mod bundle_tests; #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 5dfa4093..48e44e88 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -37,6 +37,9 @@ pub struct Manifest { pub version: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub description: String, + /// Who made it, as they typed it (plugin directories show it; nothing checks it). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub author: String, /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings @@ -294,6 +297,7 @@ impl Manifest { } self.version = clean("version", &self.version, 32)?; self.description = clean("description", &self.description, MAX_TEXT)?; + self.author = clean("author", &self.author, MAX_NAME)?; check_main(&self.main)?; if self.api == 0 || self.api > API_VERSION { return Err(format!("plugin API {} isn't supported (this FigCraft runs API {API_VERSION})", self.api)); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 64782788..e5bc2301 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -164,6 +164,7 @@ name = "English" "Bring the main file's changes into the branch" = "Bring the main file's changes into the branch" "Bring to front" = "Bring to front" "Bristle" = "Bristle" +"Browse" = "Browse" "Brush" = "Brush" "Brush rotation jitter" = "Brush rotation jitter" "Brush scatter" = "Brush scatter" @@ -397,6 +398,7 @@ name = "English" "Empty" = "Empty" "End" = "End" "End point" = "End point" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Enter the address of a figcraft-server and press Browse to see the plugins it offers." "Enter to save · Esc to cancel" = "Enter to save · Esc to cancel" "Eraser" = "Eraser" "Esc" = "Esc" @@ -482,6 +484,7 @@ name = "English" "Frames" = "Frames" "From the library \"{name}\": read-only" = "From the library \"{name}\": read-only" "From {name}" = "From {name}" +"From {server}" = "From {server}" "Frost" = "Frost" "Gap" = "Gap" "Gap between columns" = "Gap between columns" @@ -558,6 +561,10 @@ name = "English" "Inside" = "Inside" "Inspect" = "Inspect" "Inspect the layer under the pointer: size, colours, typography" = "Inspect the layer under the pointer: size, colours, typography" +"Install plugin" = "Install plugin" +"Install" = "Install" +"Installed {name}" = "Installed {name}" +"Installed" = "Installed" "Instance" = "Instance" "Instance properties" = "Instance properties" "Instance swap" = "Instance swap" @@ -571,6 +578,7 @@ name = "English" "Intersects" = "Intersects" "Invite link" = "Invite link" "Invite link copied" = "Invite link copied" +"It asks permission to:" = "It asks permission to:" "Join" = "Join" "Join a session…" = "Join a session…" "Join live session" = "Join live session" @@ -738,6 +746,7 @@ name = "English" "No number variables fit here." = "No number variables fit here." "No number variables yet. Create them in Local variables." = "No number variables yet. Create them in Local variables." "No paints" = "No paints" +"No plugins on {server}." = "No plugins on {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "No plugins yet. Import a development plugin from its figcraft-plugin.json." "No preferred components" = "No preferred components" "No preset" = "No preset" @@ -853,6 +862,7 @@ name = "English" "Pixel preview 2x" = "Pixel preview 2x" "Place automatically" = "Place automatically" "Place image" = "Place image" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." "Plugins" = "Plugins" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" @@ -989,6 +999,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"SHA-256: {sha}…" = "SHA-256: {sha}…" "Same as layer" = "Same as layer" "Saturation" = "Saturation" "Save" = "Save" @@ -1006,6 +1017,7 @@ name = "English" "Scroll to" = "Scroll to" "Scroll to layer" = "Scroll to layer" "Scroll with parent" = "Scroll with parent" +"Search plugins" = "Search plugins" "Search" = "Search" "Search actions, layers and tools" = "Search actions, layers and tools" "Search assets" = "Search assets" @@ -1031,6 +1043,7 @@ name = "English" "Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" "Send backward" = "Send backward" "Send to back" = "Send to back" +"Server address (ws://host:47900/)" = "Server address (ws://host:47900/)" "Server address" = "Server address" "Set as default" = "Set as default" "Set variable" = "Set variable" @@ -1210,10 +1223,12 @@ name = "English" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" "Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard" "Up" = "Up" +"Update plugin" = "Update plugin" "Update" = "Update" "Update from main" = "Update from main" "Update from selection" = "Update from selection" "Updated {components} and {styles}" = "Updated {components} and {styles}" +"Updated {name}" = "Updated {name}" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." "Uppercase" = "Uppercase" "Use a fixed value" = "Use a fixed value" @@ -1300,12 +1315,16 @@ name = "English" "Zoom tool: zoom out" = "Zoom tool: zoom out" "and {n} more" = { one = "and {n} more", other = "and {n} more" } "bleed marks" = "bleed marks" +"by {author}" = "by {author}" +"call any command its access allows" = "call any command its access allows" +"call only: {commands}" = "call only: {commands}" "can edit the document" = "can edit the document" "crop marks" = "crop marks" "development: {path}" = "development: {path}" "e.g. count + 1" = "e.g. count + 1" "e.g. count >= 3 and not done" = "e.g. count >= 3 and not done" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session." +"from {server}" = "from {server}" "hidden" = "hidden" "iOS" = "iOS" "locked" = "locked" @@ -1313,6 +1332,9 @@ name = "English" "nobody else here yet" = "nobody else here yet" "now" = "now" "or" = "or" +"put a widget on the canvas" = "put a widget on the canvas" +"read and edit the document" = "read and edit the document" +"read the document" = "read the document" "reads the document" = "reads the document" "the plugin" = "the plugin" "this session only" = "this session only" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 7fc494e7..42db8144 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -165,6 +165,7 @@ status = "draft — needs native review" "Bring the main file's changes into the branch" = "Trae a la rama los cambios del archivo principal" "Bring to front" = "Traer al frente" "Bristle" = "Cerdas" +"Browse" = "Explorar" "Brush" = "Pincel" "Brush rotation jitter" = "Variación de rotación del pincel" "Brush scatter" = "Dispersión del pincel" @@ -398,6 +399,7 @@ status = "draft — needs native review" "Empty" = "Vacía" "End" = "Final" "End point" = "Punto final" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Escribe la dirección de un figcraft-server y pulsa Explorar para ver los plugins que ofrece." "Enter to save · Esc to cancel" = "Intro para guardar · Esc para cancelar" "Eraser" = "Borrador" "Esc" = "Esc" @@ -483,6 +485,7 @@ status = "draft — needs native review" "Frames" = "Marcos" "From the library \"{name}\": read-only" = "De la biblioteca \"{name}\": solo lectura" "From {name}" = "De {name}" +"From {server}" = "De {server}" "Frost" = "Escarcha" "Gap" = "Espacio" "Gap between columns" = "Espacio entre columnas" @@ -559,6 +562,10 @@ status = "draft — needs native review" "Inside" = "Interior" "Inspect" = "Inspeccionar" "Inspect the layer under the pointer: size, colours, typography" = "Inspeccionar la capa bajo el puntero: tamaño, colores, tipografía" +"Install plugin" = "Instalar plugin" +"Install" = "Instalar" +"Installed {name}" = "{name} instalado" +"Installed" = "Instalados" "Instance" = "Instancia" "Instance properties" = "Propiedades de instancia" "Instance swap" = "Intercambio de instancia" @@ -572,6 +579,7 @@ status = "draft — needs native review" "Intersects" = "Intersecciones" "Invite link" = "Enlace de invitación" "Invite link copied" = "Enlace de invitación copiado" +"It asks permission to:" = "Pide permiso para:" "Join" = "Unirse" "Join a session…" = "Unirse a una sesión…" "Join live session" = "Unirse a sesión en vivo" @@ -739,6 +747,7 @@ status = "draft — needs native review" "No number variables fit here." = "Ninguna variable numérica encaja aquí." "No number variables yet. Create them in Local variables." = "Aún no hay variables numéricas. Créalas en Variables locales." "No paints" = "Sin pinturas" +"No plugins on {server}." = "No hay plugins en {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aún no hay plugins. Importa un plugin de desarrollo desde su figcraft-plugin.json." "No preferred components" = "Sin componentes preferidos" "No preset" = "Sin preajuste" @@ -854,6 +863,7 @@ status = "draft — needs native review" "Pixel preview 2x" = "Vista previa de píxeles 2x" "Place automatically" = "Colocar automáticamente" "Place image" = "Colocar imagen" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza." "Plugins" = "Plugins" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" @@ -990,6 +1000,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"SHA-256: {sha}…" = "SHA-256: {sha}…" "Same as layer" = "Igual que la capa" "Saturation" = "Saturación" "Save" = "Guardar" @@ -1007,6 +1018,7 @@ status = "draft — needs native review" "Scroll to" = "Desplazar a" "Scroll to layer" = "Desplazar a la capa" "Scroll with parent" = "Desplazar con el padre" +"Search plugins" = "Buscar plugins" "Search" = "Buscar" "Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" @@ -1032,6 +1044,7 @@ status = "draft — needs native review" "Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas cambian su tamaño, Intro selecciona, Esc cancela" "Send backward" = "Enviar atrás" "Send to back" = "Enviar al fondo" +"Server address (ws://host:47900/)" = "Dirección del servidor (ws://host:47900/)" "Server address" = "Dirección del servidor" "Set as default" = "Establecer como predeterminado" "Set variable" = "Establecer variable" @@ -1211,10 +1224,12 @@ status = "draft — needs native review" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaña con {alt}+clic para descartarlos" "Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos" "Up" = "Arriba" +"Update plugin" = "Actualizar plugin" "Update" = "Actualizar" "Update from main" = "Actualizar desde la principal" "Update from selection" = "Actualizar desde la selección" "Updated {components} and {styles}" = "Actualizados {components} y {styles}" +"Updated {name}" = "{name} actualizado" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver." "Uppercase" = "Mayúsculas" "Use a fixed value" = "Usar un valor fijo" @@ -1301,12 +1316,16 @@ status = "draft — needs native review" "Zoom tool: zoom out" = "Herramienta de zoom: alejar" "and {n} more" = { one = "y {n} más", other = "y {n} más" } "bleed marks" = "marcas de sangrado" +"by {author}" = "de {author}" +"call any command its access allows" = "llamar a cualquier comando que su acceso permita" +"call only: {commands}" = "llamar solo a: {commands}" "can edit the document" = "puede editar el documento" "crop marks" = "marcas de corte" "development: {path}" = "desarrollo: {path}" "e.g. count + 1" = "p. ej. count + 1" "e.g. count >= 3 and not done" = "p. ej. count >= 3 and not done" "figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión." +"from {server}" = "de {server}" "hidden" = "oculta" "iOS" = "iOS" "locked" = "bloqueada" @@ -1314,6 +1333,9 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "now" = "ahora" "or" = "o" +"put a widget on the canvas" = "poner un widget en el lienzo" +"read and edit the document" = "leer y editar el documento" +"read the document" = "leer el documento" "reads the document" = "lee el documento" "the plugin" = "el plugin" "this session only" = "solo esta sesión" @@ -1633,6 +1655,10 @@ status = "draft — needs native review" "page.setBackground" = "Fondo de la página" "paint.documentColors" = "Colores del documento" "plugin.close" = "Cerrar panel del plugin" +"plugin.directory.install" = "Instalar plugin del directorio" +"plugin.directory.list" = "Explorar directorio de plugins" +"plugin.directory.publish" = "Publicar plugin en el directorio" +"plugin.directory.unpublish" = "Retirar plugin del directorio" "plugin.event" = "Usar control del panel del plugin" "plugin.import" = "Importar plugin desde manifiesto…" "plugin.list" = "Listar plugins" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f9ff4ec9..e0a5699a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -335,6 +335,17 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) { app.ui.plugins_window = h; } + // The Plugins window's tab ("installed", "browse"), and the plugin of the listed + // directory whose install asks for confirmation (null: none). + if let Some(tab) = p.get("pluginsTab").and_then(Value::as_str) { + app.ui.plugins_manage.tab = + if tab == "browse" { crate::plugins_ui::PluginsTab::Browse } else { crate::plugins_ui::PluginsTab::Installed }; + } + match p.get("pluginConfirm") { + Some(Value::String(id)) => app.ui.plugins_manage.confirm = Some(id.clone()), + Some(Value::Null) => app.ui.plugins_manage.confirm = None, + _ => {} + } // A plugin command's parameters form: {id, command?}; null closes it. match p.get("pluginForm") { Some(Value::Null) => app.ui.plugin_form = None, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b3b8dd54..02fd0cac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,8 @@ mod perf_tests; #[cfg(test)] mod plugin_panel_tests; #[cfg(test)] +mod plugins_browse_tests; +#[cfg(test)] mod popup_width_tests; #[cfg(test)] mod present_tests; @@ -316,6 +318,8 @@ pub struct UiState { pub nav: a11y::Nav, /// The Manage plugins window is open. pub plugins_window: bool, + /// The Manage plugins window's tab, directory fields and pending install. + pub plugins_manage: plugins_ui::PluginsWindow, /// A plugin command's parameters form, when open. pub plugin_form: Option, /// Live sessions: remote cursors as drawn, the Join dialog. @@ -392,6 +396,7 @@ impl Default for UiState { libraries: false, nav: a11y::Nav::default(), plugins_window: false, + plugins_manage: plugins_ui::PluginsWindow::default(), plugin_form: None, live: live::LiveUi::default(), confirm_close: false, diff --git a/crates/ui-egui/src/plugins_browse_tests.rs b/crates/ui-egui/src/plugins_browse_tests.rs new file mode 100644 index 00000000..bcb04c15 --- /dev/null +++ b/crates/ui-egui/src/plugins_browse_tests.rs @@ -0,0 +1,112 @@ +//! The Manage plugins window's Browse tab, driven headless against a scripted directory: Install +//! only asks (it shows the permissions, nothing is downloaded), the confirmation's Install installs, +//! and the installed plugin's row then offers Remove. + +use std::sync::{Arc, Mutex}; + +use egui::Pos2; +use figcraft_engine::Session; +use figcraft_engine::figcraft_collab::message::{ClientMsg, PluginEntry, PluginReply, PluginReq}; +use figcraft_engine::live::{Event, HostOptions, LiveLink, Presence, SessionInfo}; +use figcraft_engine::plugin_directory::bundle::{Bundle, sha256_hex}; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::plugins_ui::PluginsTab; + +const ID: &str = "org.example.grid"; + +struct Link(Arc>, Vec); + +impl LiveLink for Link { + fn host(&mut self, _: HostOptions) -> Result { + Err("no".into()) + } + fn join(&mut self, _: &str, _: &str) -> Result<(), String> { + Err("no".into()) + } + fn leave(&mut self) {} + fn presence(&mut self, _: &Presence) {} + fn send(&mut self, _: ClientMsg) {} + fn poll(&mut self) -> Vec { + Vec::new() + } + fn plugins(&mut self, _: &str, req: PluginReq) -> Result { + let entry = PluginEntry { + id: ID.into(), + name: "Grid maker".into(), + version: "1.0.0".into(), + author: "Ada".into(), + access: "write".into(), + commands: vec!["shape.rectangle".into()], + bytes: self.1.len() as u64, + sha256: sha256_hex(&self.1), + ..PluginEntry::default() + }; + Ok(match req { + PluginReq::List { .. } => PluginReply::List(vec![entry]), + PluginReq::Fetch { .. } => { + *self.0.lock().unwrap() += 1; + PluginReply::Bundle { entry, bundle: self.1.clone() } + } + _ => PluginReply::Failed { reason: "no".into() }, + }) + } +} + +fn centre(n: &egui::accesskit::Node) -> Pos2 { + let b = n.bounds().unwrap(); + Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// Every node labelled `label`, in tree order. +fn named(u: &mut Ui, label: &str) -> Vec { + let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + tree.into_iter().map(|(_, n)| n).filter(|n| n.label() == Some(label)).collect() +} + +/// Some node's label (a text's value) holds `text`. +fn shows(u: &mut Ui, text: &str) -> bool { + let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + tree.iter().any(|(_, n)| n.label().or(n.value()).is_some_and(|l| l.contains(text))) +} + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); +} + +#[test] +fn install_asks_first_and_then_installs() { + let wasm = + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap(); + let manifest = json!({"id": ID, "name": "Grid maker", "version": "1.0.0", "main": "g.wasm", "permissions": {"access": "write", "commands": ["shape.rectangle"]}}); + let bytes = Bundle { manifest: manifest.to_string(), wasm, assets: Vec::new() }.encode(); + let fetched = Arc::new(Mutex::new(0)); + let mut s = Session::new(); + s.live.install(Box::new(Link(fetched.clone(), bytes))); + s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.plugins_window = true; + u.app.ui.plugins_manage.tab = PluginsTab::Browse; + u.ctx.enable_accesskit(); + u.frame(vec![]); + let row = named(&mut u, "Install"); + assert_eq!(row.len(), 1, "the plugin's row offers Install"); + let row_at = centre(&row[0]); + click(&mut u, row_at); + assert_eq!(u.app.ui.plugins_manage.confirm.as_deref(), Some(ID), "Install asks first"); + assert!(u.app.session.prefs.plugins.is_empty()); + assert_eq!(*fetched.lock().unwrap(), 0, "nothing is downloaded before the user confirms"); + u.frame(vec![]); + for shown in ["read and edit the document", "call only: shape.rectangle", "From ws://127.0.0.1:9/"] { + assert!(shows(&mut u, shown), "the confirmation shows {shown:?}"); + } + let confirm = named(&mut u, "Install").into_iter().map(|n| centre(&n)).find(|p| p.distance(row_at) > 4.0).unwrap(); + click(&mut u, confirm); + assert_eq!(*fetched.lock().unwrap(), 1); + assert_eq!(u.app.session.prefs.plugins.len(), 1, "installed"); + assert_eq!(u.app.ui.plugins_manage.confirm, None); + u.frame(vec![]); + assert!(named(&mut u, "Install").is_empty() && !named(&mut u, "Remove").is_empty(), "the row now offers Remove"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index cdc660a9..ea421d04 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -1,15 +1,17 @@ //! Plugins in the UI: the installed plugins in the Plugins menu (widgets in Plugins ▸ Widgets), the //! parameters form a command with parameters asks for before it runs, the panels running plugins -//! show, and the Manage plugins window. Everything runs through the engine's `plugin.*` and -//! `widget.*` commands. +//! show, and the Manage plugins window (installed plugins, and a server's plugin directory to +//! browse, install from and update from). Everything runs through the engine's `plugin.*`, +//! `plugin.directory.*` and `widget.*` commands. use egui::{Align2, Context, Id, RichText, Ui}; +use figcraft_engine::plugin_directory::{PluginEntry, installed_version, newer_version}; use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind}; use serde_json::{Map, Value, json}; use crate::FigApp; use crate::colorpicker::PickerTarget; -use crate::dialogs::{floating, window}; +use crate::dialogs::{floating, tab_strip, window}; use crate::i18n::{tr, trf}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, FieldEdit, Lead}; @@ -22,6 +24,36 @@ pub struct PluginForm { pub values: Map, } +/// The Manage plugins window's tabs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PluginsTab { + #[default] + Installed, + Browse, +} + +impl PluginsTab { + pub const ALL: [PluginsTab; 2] = [PluginsTab::Installed, PluginsTab::Browse]; + + fn label(self) -> &'static str { + match self { + PluginsTab::Installed => tr("Installed"), + PluginsTab::Browse => tr("Browse"), + } + } +} + +/// The Manage plugins window's state: its tab, the Browse tab's fields and the plugin waiting for +/// the user to confirm its install (by id). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PluginsWindow { + pub tab: PluginsTab, + /// The server address typed (`None`: the one browsed last). + pub server: Option, + pub query: String, + pub confirm: Option, +} + /// Width of the plugin windows' fields. const FIELD_W: f32 = 220.0; /// How far apart plugin panels open, side by side (a panel and a gap). @@ -194,43 +226,226 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) { } } +/// What the Manage plugins window asks for. +enum Manage { + Remove(String), + Import, + Browse(String), + Confirm(String), +} + +/// Width of the Manage plugins window, and of a plugin's text beside its button. +const MANAGE_W: f32 = 400.0; +const ROW_TEXT_W: f32 = 300.0; + fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; - let mut remove = None; - let mut import = false; + let mut asked = None; + let mut tab = app.ui.plugins_manage.tab; window(tr("Plugins"), ctx).id(Id::new("Plugins")).open(&mut open).show(ctx, |ui| { - ui.set_width(380.0); - let plugins = &app.session.prefs.plugins; - if plugins.is_empty() { - widgets::label(ui, tr("No plugins yet. Import a development plugin from its figcraft-plugin.json."), t.text_secondary); + ui.set_width(MANAGE_W); + tab_strip(ui, &t, &PluginsTab::ALL, PluginsTab::label, &mut tab); + ui.add_space(12.0); + asked = match tab { + PluginsTab::Installed => installed_tab(app, ui, &t), + PluginsTab::Browse => browse_tab(app, ui, &t), + }; + }); + app.ui.plugins_manage.tab = tab; + app.ui.plugins_window = open; + match asked { + Some(Manage::Remove(id)) => { + app.run("plugin.remove", json!({"id": id})); } - for (i, p) in plugins.iter().enumerate() { + Some(Manage::Import) => import_dialog(app), + Some(Manage::Browse(server)) => { + app.ui.plugins_manage.confirm = None; + app.run("plugin.directory.list", json!({"server": server})); + } + Some(Manage::Confirm(id)) => app.ui.plugins_manage.confirm = Some(id), + None => {} + } + if open && app.ui.plugins_manage.confirm.is_some() { + confirm_window(app, ctx, &t); + } +} + +fn installed_tab(app: &FigApp, ui: &mut Ui, t: &Tokens) -> Option { + let mut asked = None; + let plugins = &app.session.prefs.plugins; + if plugins.is_empty() { + widgets::label(ui, tr("No plugins yet. Import a development plugin from its figcraft-plugin.json."), t.text_secondary); + } + for (i, p) in plugins.iter().enumerate() { + if i > 0 { + ui.add_space(8.0); + } + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(ROW_TEXT_W); + ui.label(RichText::new(&p.manifest.name).font(semibold(12.0)).color(t.text)); + widgets::label(ui, &summary(p), t.text_secondary); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if widgets::button(ui, tr("Remove"), false, true).clicked() { + asked = Some(Manage::Remove(p.manifest.id.clone())); + } + }); + }); + } + ui.add_space(12.0); + if widgets::button(ui, tr("Import plugin from manifest…"), true, true).clicked() { + asked = Some(Manage::Import); + } + asked +} + +/// A server's plugin directory: its address, a search, and its plugins with Install, Update or +/// Remove. Nothing is fetched until Browse is pressed. +fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { + let mut asked = None; + let mut server = app.ui.plugins_manage.server.clone().or_else(|| app.session.prefs.plugin_directory.clone()).unwrap_or_default(); + ui.horizontal(|ui| { + // The field commits on Enter (which also browses) or when Browse takes the focus. + let mut enter = false; + if let Some(s) = + widgets::text_field(ui, Id::new("plugin_directory_server"), &server, MANAGE_W - 90.0, tr("Server address (ws://host:47900/)")) + { + enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + server = s.clone(); + app.ui.plugins_manage.server = Some(s); + } + if (widgets::button(ui, tr("Browse"), true, true).clicked() || enter) && !server.trim().is_empty() { + asked = Some(Manage::Browse(server.trim().to_string())); + } + }); + let Some(listing) = app.session.plugins.directory.as_ref() else { + ui.add_space(8.0); + widgets::label(ui, tr("Enter the address of a figcraft-server and press Browse to see the plugins it offers."), t.text_secondary); + return asked; + }; + ui.add_space(8.0); + if let Some(q) = widgets::text_field(ui, Id::new("plugin_directory_query"), &app.ui.plugins_manage.query, MANAGE_W, tr("Search plugins")) { + app.ui.plugins_manage.query = q; + } + ui.add_space(8.0); + let q = app.ui.plugins_manage.query.trim().to_lowercase(); + let shown: Vec<&PluginEntry> = listing + .entries + .iter() + .filter(|e| q.is_empty() || [&e.name, &e.description, &e.author, &e.id].iter().any(|f| f.to_lowercase().contains(&q))) + .collect(); + if shown.is_empty() { + widgets::label(ui, &trf("No plugins on {server}.", &[("server", &listing.server)]), t.text_secondary); + } + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (i, e) in shown.iter().enumerate() { if i > 0 { - ui.add_space(8.0); + ui.add_space(10.0); } ui.horizontal(|ui| { ui.vertical(|ui| { - ui.set_width(280.0); - ui.label(RichText::new(&p.manifest.name).font(semibold(12.0)).color(t.text)); - widgets::label(ui, &summary(p), t.text_secondary); + ui.set_width(ROW_TEXT_W); + ui.label(RichText::new(&e.name).font(semibold(12.0)).color(t.text)); + widgets::label(ui, &entry_summary(e), t.text_secondary); + if !e.description.is_empty() { + ui.add(egui::Label::new(RichText::new(&e.description).font(regular(11.0)).color(t.text)).wrap()); + } + }); + let (label, primary, ask): (&str, bool, fn(String) -> Manage) = match installed_version(&app.session, &e.id) { + None => (tr("Install"), true, Manage::Confirm), + Some(have) if newer_version(&e.version, have) => (tr("Update"), true, Manage::Confirm), + Some(_) => (tr("Remove"), false, Manage::Remove), + }; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if widgets::button(ui, label, primary, true).clicked() { + asked = Some(ask(e.id.clone())); + } }); - if widgets::button(ui, tr("Remove"), false, true).clicked() { - remove = Some(p.manifest.id.clone()); - } }); } - ui.add_space(12.0); - if widgets::button(ui, tr("Import plugin from manifest…"), true, true).clicked() { - import = true; - } }); - app.ui.plugins_window = open; - if let Some(id) = remove { - app.run("plugin.remove", json!({"id": id})); + asked +} + +/// "1.2.0 · by Ada · 54 KB · can edit the document" under a directory plugin's name. +fn entry_summary(e: &PluginEntry) -> String { + let mut parts = vec![e.version.clone()]; + if !e.author.is_empty() { + parts.push(trf("by {author}", &[("author", &e.author)])); } - if import { - import_dialog(app); + parts.push(size(e.bytes)); + parts.push(access_text(&e.access).to_string()); + parts.join(" · ") +} + +fn access_text(access: &str) -> &'static str { + if access == "write" { tr("can edit the document") } else { tr("reads the document") } +} + +fn size(bytes: u64) -> String { + match bytes { + b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / f64::from(1 << 20)), + b if b >= 1 << 10 => format!("{} KB", b.div_ceil(1 << 10)), + b => format!("{b} B"), + } +} + +/// Installing runs a plugin's code: this window shows what it asks permission for, where it comes +/// from and its SHA-256, and installs only when Install is pressed. +fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { + let id = app.ui.plugins_manage.confirm.clone().unwrap_or_default(); + let found = app.session.plugins.directory.as_ref().and_then(|l| Some((l.server.clone(), l.entries.iter().find(|e| e.id == id)?.clone()))); + let Some((server, e)) = found else { + app.ui.plugins_manage.confirm = None; + return; + }; + let update = installed_version(&app.session, &e.id).is_some(); + let title = if update { tr("Update plugin") } else { tr("Install plugin") }; + let (mut open, mut install, mut cancel) = (true, false, false); + window(title, ctx).id(Id::new("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { + ui.set_width(340.0); + ui.label(RichText::new(format!("{} {}", e.name, e.version)).font(semibold(13.0)).color(t.text)); + widgets::label(ui, &trf("From {server}", &[("server", &server)]), t.text_secondary); + ui.add_space(10.0); + ui.label(RichText::new(tr("It asks permission to:")).font(semibold(11.0)).color(t.text)); + let mut asks = vec![if e.access == "write" { tr("read and edit the document") } else { tr("read the document") }.to_string()]; + asks.push(if e.commands.is_empty() { + tr("call any command its access allows").to_string() + } else { + trf("call only: {commands}", &[("commands", &e.commands.join(", "))]) + }); + if e.widget { + asks.push(tr("put a widget on the canvas").to_string()); + } + for a in asks { + ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); + } + ui.add_space(8.0); + widgets::label( + ui, + tr("Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust."), + t.text_secondary, + ); + ui.add_space(4.0); + widgets::label(ui, &trf("SHA-256: {sha}…", &[("sha", &e.sha256.get(..16).unwrap_or_default())]), t.text_secondary); + ui.add_space(12.0); + ui.horizontal(|ui| { + install = widgets::button(ui, if update { tr("Update") } else { tr("Install") }, true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); + }); + }); + if !open || cancel || install { + app.ui.plugins_manage.confirm = None; + } + if install + && let Some(v) = + app.run("plugin.directory.install", json!({"id": e.id, "version": e.version, "sha256": e.sha256, "server": server, "confirm": true})) + { + let name = v["name"].as_str().unwrap_or(tr("the plugin")).to_string(); + let args: &[(&str, &dyn std::fmt::Display)] = &[("name", &name)]; + app.toast(if update { trf("Updated {name}", args) } else { trf("Installed {name}", args) }); } } @@ -246,9 +461,10 @@ fn summary(p: &InstalledPlugin) -> String { parts.push(m.version.clone()); } parts.push(access.to_string()); - parts.push(match &p.path { - Some(path) => trf("development: {path}", &[("path", path)]), - None => tr("this session only").into(), + parts.push(match (&p.source, &p.path) { + (Some(server), _) => trf("from {server}", &[("server", server)]), + (None, Some(path)) => trf("development: {path}", &[("path", path)]), + (None, None) => tr("this session only").into(), }); parts.join(" · ") } diff --git a/docs/cli.md b/docs/cli.md index c0aee31b..ca38145a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -105,7 +105,17 @@ machines. Serve MCP on stdio: headless by default, or attached to a running app started with `figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish -live sessions (`collab.*`); the one-shot `run` can't (no network link). +live sessions (`collab.*`) and use plugin directories (`plugin.directory.*`); the one-shot `run` +can't (no network link). + +### `plugin publish --server ws://host:port/[?key=KEY]` + +Publishes a plugin folder (`figcraft-plugin.json`, the module its `main` names, an optional `assets/` +folder) to a `figcraft-server`'s plugin directory as a new version, and prints its name, version, +size and SHA-256. The server's key comes from the address or, better, from the +`FIGCRAFT_SERVER_KEY` environment variable (it then stays out of the shell history and process +lists); it is never printed. The bundle is checked before it is sent and again by the server. See +[plugins.md](plugins.md#plugin-directories). ### `perf [--layers N[,N...]]` diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 614efdfd..62b16a76 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -194,6 +194,8 @@ Client → server: | `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. | | `Library {fetch {have?, usage?} \| publish {archive} \| usage}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`. | | `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed) or `spotlight` (`"on"`, or empty: stopped). | +| `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. | +| `Plugins {list {query} \| fetch {id, version?} \| publish {bundle} \| unpublish {id, version?}}` | A request on a plugin directory connection; each gets one `Plugins` reply, in order. `bundle` is a plugin bundle's bytes (`figcraft_plugin::bundle`). | Server → client: @@ -208,6 +210,7 @@ Server → client: | `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. | | `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason}}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not. | | `Signal {client_id, signal: {kind, text}}` | Someone's cursor chat or spotlight, cleaned by the server; `ready` (about the client itself, after its welcome): signals may be sent. Only to clients whose hello announced `signals`. | +| `Plugins {ready {publisher} \| list [entry] \| bundle {entry, bundle} \| published entry \| removed [version] \| failed {reason}}` | A plugin directory connection's answer: opened (with the key or not), the latest version of each plugin with the versions kept, a bundle and its entry (its SHA-256 among them), kept, unpublished, or why not. | `Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put` @@ -551,6 +554,24 @@ resuming with its offline edits, and leaving the session going back to snapshot the server keeps the latest report per file name (`usage.json`, rewritten only when it changes, capped) and answers `usage` with them. `library.usage` adds them up with the components' names. +*Built* (the plugin directory; operating it: [`server.md`](server.md#plugin-directory), publishing and +installing: [`plugins.md`](plugins.md#plugin-directories)): + +- **A plugin directory connection.** A connection that opens with `OpenPlugins {key}` lists and + fetches plugins with no credential at all, and publishes or unpublishes them when `key` is the + server's (a wrong key is refused like a managing connection's). Requests are one-shot, like a + library connection's. A server older than the directory can't read `OpenPlugins` and refuses the + connection; the app says the server has no plugin directory. +- **What travels.** A plugin bundle: a magic number, then length-prefixed parts (the manifest's + JSON, the module, up to 32 named assets), uncompressed, so nothing expands beyond the bytes sent. + Its SHA-256 names it on the server (`plugins/.bin`) and in every entry; the app checks a + download against it before decoding anything. +- **Checks.** The server decodes a published bundle within its size cap, parses the manifest + (refusing a plugin API it doesn't run), requires a version of 1 to 4 numbers that is newer than + the one kept, compiles the module without running it (only the sandbox's imports allowed) and + checks the asset names; the app checks the same again after the hash. Fetches share a budget of + bytes per second on the server, so many downloads at once are turned away instead of queued. + *Built* (opening invite links): - The desktop app takes an invite link on its command line (`figcraft "figcraft://join?…"`): once diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b9bd030c..a04e1f55 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -161,12 +161,14 @@ separate calls. Returns `{queued}`. Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginForm?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage -plugins…), `pluginForm` (`{id, command?}`: start that plugin command like its menu item, opening +plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the +directory listed last: its install confirmation shows, as its Install button would; `null` closes +it), `pluginForm` (`{id, command?}`: start that plugin command like its menu item, opening its parameters form when it has parameters; `null` closes the form), `focusRegion` (`toolbar|layers|canvas|properties`: give that region the keyboard as F6 would, on the next frame), `iconGallery` (bool: diff --git a/docs/gaps.md b/docs/gaps.md index e09d4f7e..b74bd700 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English plus unreviewed Spanish, French, German, Brazilian Portuguese, Japanese and Korean drafts | Localization | 32–55 | [localization-parity](localization-parity.md) | -| 6 | Plugins: no distribution, widgets take clicks only; Figma's plugins don't run | Ecosystem | 10–30 | [target-app-parity](target-app-parity.md) | +| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | @@ -118,7 +118,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe [`localization-parity.md`](localization-parity.md); how to add one in [`translating.md`](translating.md). -### 6. Plugins: no distribution, widgets take clicks only; Figma's plugins don't run +### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run - **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in the browser) acting through the command registry, one undo step per run, manifest permissions, Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples @@ -131,15 +131,19 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe on the parts it names (one undo step each, a fresh metered sandbox, the state kept in the layer so it saves, copies, undoes, exports and syncs), `widget.click` / `widget.list` for agents, a Counter example; panels of several plugins side by side; the colour picker on plugin colour - fields. -- **Missing:** distribution (a plugin directory), Community files; widgets take only clicks (no + fields. A plugin directory on `figcraft-server` (2026-10-12): bundles published with the server + key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions + confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their + SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. +- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings + and install counts, background update checks, the directory in the browser build; widgets take only clicks (no text input, property menus, hover or timers) and draw only frames, text, shapes and images; no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and don't run. - **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better than Figma does. -- **Estimate:** 10–30 h for the missing pieces of FigCraft's own API. Running Figma's plugins +- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins needs a JS engine and an API shim: **owner decision needed**. ### 7. CPU canvas where Figma uses the GPU @@ -336,6 +340,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version, operator moderation) added to gap 6's landed list; gap 6 10–30 h → 8–26 h; ranking unchanged | | 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Gap 5 40–65 h → 32–55 h, rank unchanged | | 2026-10-12 | minor | Plugin widgets (API 3) and the panel tail (panels side by side, colour picker on plugin fields) added to gap 6's landed list; gap 6 now 10–30 h; ranking unchanged | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Gap 5 45–75 h → 40–65 h, rank unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 8828c2d0..551bcefc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -946,6 +946,20 @@ state is layer data, so it is saved, undone and synced with the file. {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}} ``` +Plugins from a `figcraft-server`'s plugin directory ([`plugins.md`](plugins.md#plugin-directories)): +`plugin.directory.list {server?, query?}` lists the latest version of each plugin with the +permissions it asks for (`access`, `commands`), its size and SHA-256, and `installed` / `update`; +`plugin.directory.install {id, version?, confirm: true, sha256?}` downloads it, checks the SHA-256 and +installs it for the session (an agent must pass `confirm: true`, after showing its user the +permissions: without it nothing is downloaded); `plugin.directory.publish {path | plugin, server}` and +`plugin.directory.unpublish {id, version?, server}` need the server's key in `server` +(`ws://host:port/?key=…`). + +```json +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.directory.list","params":{"server":"ws://127.0.0.1:47900/"}}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.directory.install","params":{"id":"org.figcraft.examples.swatch-grid","confirm":true}}}} +``` + ### Dev Mode - **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a diff --git a/docs/plugins.md b/docs/plugins.md index 0ecd4f80..2f78232b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -21,6 +21,10 @@ permissions its manifest asks for, and nothing more. parts calls the plugin back to redraw it from its state, which is kept in the document. - **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and can narrow the commands it may call. FigCraft enforces both. +- **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are + published to it with the server's key, browsed and installed from Plugins ▸ Manage plugins, and + updated when a newer version is published (see [Plugin directories](#plugin-directories)). No + third-party service is involved. ## Using plugins @@ -32,8 +36,10 @@ permissions its manifest asks for, and nothing more. with a field per parameter, drawn with FigCraft's own widgets; **Run** runs it. - **Plugins ▸ Run last plugin** (⌥⌘P on macOS, Ctrl+Alt+Shift+P on Windows and Linux, where Ctrl+Alt+P is Pixel preview) runs the last plugin command again with the same parameters. -- **Plugins ▸ Manage plugins…** lists the installed plugins (version, access, manifest path) with a - Remove button for each, and an Import button. +- **Plugins ▸ Manage plugins…** has two tabs. **Installed** lists the installed plugins (version, + access, where each comes from: its manifest path or the server it was installed from) with a + Remove button for each, and an Import button. **Browse** shows a server's plugin directory, with + Install, Update and Remove (see [Plugin directories](#plugin-directories)). - **Plugins ▸ Widgets** lists the installed widget plugins; picking one puts its widget in the middle of the view. @@ -99,6 +105,55 @@ plugins: import them in the script. | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | +| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | +| `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | +| `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | +| `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | + +## Plugin directories + +A `figcraft-server` ([`server.md`](server.md#plugin-directory)) can keep a directory of plugins for a +team. Anyone who can reach the server may browse it and install from it; only whoever has the +server's key may publish to it or unpublish from it. There are no accounts: the server's operator +decides who gets the key and moderates what's there. + +**Installing.** In **Plugins ▸ Manage plugins… ▸ Browse**, type the server's address +(`ws://host:47900/`, or `wss://…` behind TLS) and press **Browse**: nothing is fetched before that. +The list shows each plugin's latest version, author (as the publisher typed it), size and the +access it asks for; the search filters it. **Install** opens a confirmation that lists what the +plugin asks permission to do (read or edit the document, the commands it may call, whether it draws +a widget), the server it comes from and its SHA-256; only its **Install** button downloads and +installs it. The download must match the SHA-256 the directory lists, and the bundle is then +checked like an imported plugin (manifest, plugin API version, the module compiled but not run, +asset names) before anything is written. A plugin installed this way runs in the same sandbox, +with the same limits and its manifest's permissions, as any other. + +The desktop app keeps such plugins per user, beside its settings (`Plugins/` in FigCraft's settings +folder: `%APPDATA%\FigCraft` on Windows, `~/Library/Application Support/FigCraft` on macOS, +`~/.config/FigCraft` elsewhere), each in a folder of its own named by a hash of its id, with its +module and its assets. The CLI and the MCP server install for the session only. + +**Updating.** When the directory has a newer version than the one installed, the plugin's row +offers **Update**, with the same confirmation (the permissions may have changed). Browsing again is +the update check; nothing checks in the background. **Remove** uninstalls it and deletes its folder. + +**Publishing.** A plugin folder holds `figcraft-plugin.json`, the module its `main` names and, if +you like, an `assets/` folder of up to 32 plain files (an icon, a licence) that are installed with +it. Publish it with the server's key: + +```sh +FIGCRAFT_SERVER_KEY= figcraft-cli plugin publish target/plugins/dist/counter --server ws://host:47900/ +``` + +(the key may also be in the address, `?key=…`, but the environment keeps it out of your shell +history), or `plugin.directory.publish {path, server}` from a script or MCP. The server checks the +whole bundle before keeping it, refuses a version that isn't newer than the one it has, and lists +the new version at once. `plugin.directory.unpublish` (or the operator's `figcraft-server plugins +remove`) takes a version, or the whole plugin, down; installed copies keep working. + +**In the browser** the directory isn't available yet: the browser build's network link has no +plugin directory requests (`plugin.directory.*` says so). Import plugins with `plugin.import +{manifest, wasm}` there. ## The manifest: `figcraft-plugin.json` @@ -132,7 +187,8 @@ plugins: import them in the script. |---|---| | `id` | Required. 1–128 letters, digits, `.`, `-`, `_`; a reverse domain name is a good choice. | | `name` | Required. Shown in the Plugins menu (64 characters at most). | -| `version`, `description` | Optional text. | +| `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | +| `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | | `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings) or `3` (widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | @@ -351,8 +407,10 @@ import what you need from `figcraft`. ## Not yet -- Distribution: no plugin directory, signing or automatic updates; plugins are imported from a - manifest. +- Distribution: directories live on your own `figcraft-server`; there is no public catalogue, + no publisher accounts or signatures (the server's key is the publisher), no ratings, reviews or + install counts, and updates are found when you browse, not in the background. The browser build + can't use a directory yet. - Plugins written for other apps (JavaScript plugins) don't run. - Widgets: clicks are their only events (no text input, hover, property menu or timers), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no diff --git a/docs/roadmap.md b/docs/roadmap.md index 979c9509..e6cfae23 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-12 · **Change:** minor (Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **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 @@ -58,7 +58,7 @@ blockers. | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization (new) | not started | layer + 11 languages | 60–100 | -| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields | distribution, richer widget events; Figma plugin compatibility is an owner decision | 10–30 | +| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | | M12 AI features (new, owner decision) | not started | local models | 30–60 | ## Next, after the current focus @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M11: a plugin directory on `figcraft-server` (bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, hash-checked downloads, updates by version, `plugin.directory.*`, `figcraft-cli plugin publish`); 10–30 h → 8–26 h | | 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Current focus 4 now lists the drafts' native reviews | | 2026-10-12 | minor | M11: plugin widgets (API 3: plugin-drawn layers on the canvas, redrawn on clicks from state kept in the document), panels of several plugins side by side and the colour picker on plugin colour fields; 15–40 h → 10–30 h | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Current focus 4 now lists Japanese and Korean | diff --git a/docs/server.md b/docs/server.md index c2764d75..dc6a1409 100644 --- a/docs/server.md +++ b/docs/server.md @@ -9,7 +9,8 @@ headless, native only (no web build), and runs on the same platforms as the desk There are no user accounts yet: a share link is the access to one file, and the **server key** manages files (publishing, links, removal). Accounts and per-person permissions come later. A file can also hold a **team library** that other files use through one of its links (see -[Team libraries](#team-libraries)). +[Team libraries](#team-libraries)), and the server can keep a **plugin directory** for the team (see +[Plugin directory](#plugin-directory)). The protocol and the design behind it are in [`collab-protocol.md`](collab-protocol.md). @@ -98,6 +99,37 @@ which other files use without anyone copying the library file around. The browser build can't use team libraries yet (it has no library support of its own). +## Plugin directory + +The server keeps a directory of plugins ([`plugins.md`](plugins.md#plugin-directories)) on the same +port as everything else; no other service is involved. + +- **Who can do what.** Anyone who can reach the server lists, searches and downloads its plugins + (no link or key needed: put the server behind your network or proxy if that's too open). Only + whoever has the **server key** publishes and unpublishes, the same key that publishes files: there + are no publisher accounts, so give it only to people you trust to put code on your team's + machines. A plugin's author is shown as the publisher typed it in the manifest; nothing verifies + it. +- **Publishing.** `figcraft-cli plugin publish FOLDER --server ws://host:47900/` with the key in + `FIGCRAFT_SERVER_KEY` (or `?key=` in the address), or `plugin.directory.publish` from the app's + scripts and MCP. Each version must be newer than the last (`1.0.0` → `1.0.1`); the server keeps + every version until it is unpublished, and lists the latest. +- **What the server checks** before keeping a bundle: its size (`max_plugin_mb`), that it decodes + (an uncompressed format: nothing expands), that its manifest parses and asks for a plugin API this + server's FigCraft runs, a version of one to four numbers newer than the one kept, that the module + is WebAssembly that compiles and imports only what FigCraft's sandbox provides (it is never run on + the server), and that asset names are plain file names. Then the caps: `max_plugins` versions in + all and `max_plugins_mb` of bundles. +- **Downloads** carry the bundle's SHA-256, which the app checks before reading it, and which the + server checks again when it reads the bundle back from disk (a damaged one is never sent, and is + logged). Downloads share a budget of about 32 MiB a second, so many at once are turned away with + "busy" rather than queued in memory. +- **Moderation** is the operator's: `figcraft-server plugins` lists every plugin and its versions, + `figcraft-server plugins remove ID [--version V]` takes a version, or the whole plugin, down (it + works on a running server too). Installed copies keep working; users can't tell the server to + remove anything without the key. +- **Turning it off**: `max_plugins = 0`. + ## Managing files and links | Command | What it does | @@ -108,6 +140,8 @@ The browser build can't use team libraries yet (it has no library support of its | `figcraft-server links list FILE` | A file's links: id, access, created, label | | `figcraft-server links create FILE --access edit\|view [--label TEXT]` | A new link, printed once | | `figcraft-server links revoke FILE LINK` | Revoke a link: it no longer joins, and whoever joined with it is disconnected | +| `figcraft-server plugins [list]` | The plugin directory: every plugin, its versions, author, access, size and SHA-256 | +| `figcraft-server plugins remove ID [--version V]` | Unpublish a version of a plugin, or every version | Each takes `--data` (or `--config`). When a server is running on the directory, the command asks it over the same port with the server key (so a revoked link's users are disconnected at once); @@ -132,6 +166,9 @@ are errors): | `max_people` | 256 | Most people in one file at once | | `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_plugins` | 500 | Most plugin versions the plugin directory keeps (all plugins together); 0 turns the directory off | +| `max_plugin_mb` | 16 | Largest plugin bundle, in MiB (at most 32) | +| `max_plugins_mb` | 1024 | Most MiB of plugin bundles kept | With `listen` on every interface (`0.0.0.0`) and no `public_url`, links name this machine's network address. @@ -148,6 +185,8 @@ network address. /files//log.bin what changed since, one checksummed record per change /files//library.bin its team library, when one was published (a .figcraft archive) /files//usage.json which files use that library's components, as their apps report it +/plugins/index.json the plugin directory: every version kept (manifest fields, size, SHA-256) +/plugins/.bin a plugin bundle, named by the SHA-256 of its bytes ``` - **Writes are atomic**: a snapshot, a description or a link list is written to a temporary file @@ -162,11 +201,15 @@ network address. record loses only that end (logged when the file opens). Leftover temporary files are removed. - The server key is stored as plain text (on Unix readable by the owner only): whoever can read the data directory can read every file anyway. Link tokens are stored only as hashes. +- An index entry whose bundle is missing or the wrong size is left out at startup and reported; + bundles are deleted only when their last version is unpublished. A `plugins/index.json` that + can't be read lists nothing and refuses publishing (nothing is written over it) until it is + restored or removed. ## Backups -Copy the data directory while the server is stopped, or take an atomic snapshot of the -filesystem it lives on (ZFS, Btrfs, LVM, a VSS shadow copy). A plain copy of a running server's +Copy the data directory (the plugin directory, `plugins/`, included) while the server is stopped, +or take an atomic snapshot of the filesystem it lives on (ZFS, Btrfs, LVM, a VSS shadow copy). A plain copy of a running server's directory can catch a file between its snapshot and its log; it restores, but may lose that file's changes since its last compaction. To restore, stop the server, put the directory back and start it: apps that were in a file catch up with a fresh copy (their unsent edits are kept when @@ -352,4 +395,10 @@ sudo systemctl start figcraft-server components per file and 500 files per library. - **Team libraries**: any link of a file reads its library and its usage reports (file names and counts); only an edit link publishes it. +- **Plugin directory**: anyone who reaches the server reads it; only the server key writes to it. + Everything published is checked in full and never run on the server; a bundle's name on disk is + its hash, so nothing a publisher chooses reaches the file system; downloads are hash-checked at + both ends and share a bandwidth budget. Installing still means trusting the publisher: a plugin + runs in FigCraft's sandbox with the permissions its manifest asks for, which the app shows before + it installs anything. - **Not there yet**: user accounts, per-person permissions, rate limits per address, audit logs. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b102fff5..62d1acfb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | | Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 72.6% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~268–528 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~266–524 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~273–530 h | multiplayer (42–84), localization (32–55), plugins (10–30), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~271–526 h | multiplayer (42–84), localization (32–55), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -111,11 +111,11 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 55% | 32% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields. Left: distribution; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 10–30 | +| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **120–260** | +| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **118–256** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 33–75 | ## By dimension (ready for real work) @@ -131,9 +131,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | | Localization | 3 | 35% | 32–55 | The layer, an English catalog and complete machine drafts of Spanish, French, German, Brazilian Portuguese, Japanese and Korean (none reviewed by a native speaker), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); Latin American Spanish is missing; engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | -| Ecosystem/plugins | 3 | 34% | 10–30 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3); no Figma plugin compatibility, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | +| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~53%** | **~268–528** | | | +| **Ready for real work** | 100 | **~53%** | **~266–524** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -500,9 +500,11 @@ Hours in this narrative are the builders' estimates; the tables above supersede a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of - several plugins side by side and the colour picker on plugin colour fields. *Left:* plugin - distribution; richer widget events; for collaboration, the browser client and a standalone - server. 10–30 h. + several plugins side by side and the colour picker on plugin colour fields. Then a plugin + directory on `figcraft-server`: bundles published with the server key and checked in full, + browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads + checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher + accounts and signatures; richer widget events; the directory in the browser. 8–26 h. 9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer pages: engine, render and headless UI-frame timings). The canvas keeps its last image and re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders @@ -617,6 +619,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | | 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35% (drafts, not reviewed: graded low), 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1 by the dimension table); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h (Japanese and Korean leave the beta list; their native reviews need people) | | 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%; 52.6 → 52.9); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h (Figma's languages left on the way to beta: Japanese and Korean, 7–12 h) |