M7: WASM plugins (#101)
Sandboxed WebAssembly plugins whose API is the command registry: - figcraft-plugin (new crate, L2): the figcraft-plugin.json manifest (menu commands with parameters, read/write access, command allowlist) and the sandbox: wasmi (pure Rust, no JIT, also in the web build) with fuel metering, a memory cap, no WASI and seven host functions (call, selection, reply_len, reply_read, log, notify, set_output). - Engine: plugin.list / plugin.import / plugin.remove / plugin.run, plugins.runLast (Alt+Cmd+P; Ctrl+Alt+Shift+P on Windows/Linux) and plugins.manage. A run is one undo step (Session::grouped, which command.batch now uses too) and is rolled back when it fails. Files, other documents, the undo history, dialogs and plugins are off limits; read-only plugins can't change the document. Installed plugins live in the preferences; development plugins re-read their module every run. - UI: installed plugins in the Plugins menu, a parameters form drawn from the manifest, Manage plugins, Plugins > Development > Import plugin from manifest. - plugins/: a no_std Rust SDK and two example plugins (Tidy names, Swatch grid), built for wasm32 by `cargo xtask plugins` (part of `cargo xtask wasm`). - Docs: docs/plugins.md (manifest, permissions, limits, ABI, SDK), docs/mcp.md, docs/control-protocol.md, README, ROADMAP, parity. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d452da3452
commit
041b3f4e5d
|
|
@ -1379,6 +1379,7 @@ dependencies = [
|
|||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
"figcraft-pdf",
|
||||
"figcraft-plugin",
|
||||
"figcraft-render",
|
||||
"figcraft-svg",
|
||||
"figcraft-text",
|
||||
|
|
@ -1386,6 +1387,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
|
|
@ -1458,6 +1460,17 @@ dependencies = [
|
|||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"wasmi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1567,6 +1580,12 @@ version = "1.0.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1775,7 +1794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
|
|
@ -1922,6 +1941,15 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
|
|
@ -1930,7 +1958,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1939,7 +1967,7 @@ version = "0.17.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2326,6 +2354,12 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
|
|
@ -3888,6 +3922,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.4.0+sdk-1.4.341.0"
|
||||
|
|
@ -3918,6 +3958,16 @@ dependencies = [
|
|||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
|
|
@ -4464,6 +4514,99 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.261.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
|
||||
dependencies = [
|
||||
"spin",
|
||||
"wasmi_collections",
|
||||
"wasmi_core",
|
||||
"wasmi_ir",
|
||||
"wasmparser 0.228.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_collections"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
|
||||
dependencies = [
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_core"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_ir"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
|
||||
dependencies = [
|
||||
"wasmi_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.228.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "261.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
# The plugin SDK and examples build for wasm32 in a workspace of their own (`cargo xtask plugins`).
|
||||
exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
|
|
@ -26,6 +28,7 @@ figcraft-tools = { path = "crates/tools" }
|
|||
figcraft-engine = { path = "crates/engine" }
|
||||
figcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
|
|
@ -50,6 +53,12 @@ sha2 = "0.10"
|
|||
base64 = "0.22"
|
||||
crc32fast = "1"
|
||||
|
||||
# plugins: a pure-Rust, metered WebAssembly interpreter (no JIT; also runs in the web build). The
|
||||
# portable dispatch loop: tail-call dispatch relies on an LLVM optimisation that unoptimised and
|
||||
# debug builds may not do, and then a long-running plugin overflows the native stack.
|
||||
wasmi = { version = "2", default-features = false, features = ["std", "stable", "validate", "portable-dispatch"] }
|
||||
wat = "1.261"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
|||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
|
||||
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
|
||||
|
||||
|
|
@ -164,7 +165,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
|
|||
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in early alpha. Most of Figma's editor surface exists in some form, and the main menu is almost fully wired ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. Multiplayer, comments, version history, plugins and Dev Mode depth are not there yet. The honest, area-by-area picture is in the [roadmap](ROADMAP.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** FigCraft is in early alpha. Most of Figma's editor surface exists in some form, and the main menu is almost fully wired ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. Multiplayer, comments, version history, plugin UIs beyond parameter forms and Dev Mode depth are not there yet. The honest, area-by-area picture is in the [roadmap](ROADMAP.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
|
|
|
|||
25
ROADMAP.md
25
ROADMAP.md
|
|
@ -233,8 +233,19 @@ dimension measured side by side.
|
|||
landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel;
|
||||
view a version on the canvas read-only before restoring it; autosave checkpoints every 30
|
||||
minutes, folded in the panel). Real-time collaboration is designed (`docs/collab-protocol.md`:
|
||||
WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors);
|
||||
multiplayer and plugins are not built yet. 20–50 h.
|
||||
WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors).
|
||||
Plugins landed: sandboxed WebAssembly plugins (a metered wasmi
|
||||
interpreter with a memory cap and no WASI, also in the browser) whose API is the command
|
||||
registry; each run is one undo step and is rolled back when it fails (error, trap, endless loop,
|
||||
memory); a `figcraft-plugin.json` manifest declares menu commands with parameters and read or
|
||||
write access with an optional command allowlist, and files, other documents, the undo history,
|
||||
dialogs and other plugins are always off limits. Plugins ▸ Development ▸ Import plugin from
|
||||
manifest… (kept in the preferences, the module re-read on every run), the installed plugins in
|
||||
the Plugins menu with a parameters form drawn from the manifest, Run last plugin (⌥⌘P), Manage
|
||||
plugins, and `plugin.*` commands for scripts, the CLI and MCP; a Rust SDK and two example
|
||||
plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. *Left:*
|
||||
plugin-defined UI at run time (forms or panels a plugin returns), plugin settings storage,
|
||||
distribution. Multiplayer is not built yet. 18–45 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
|
||||
|
|
@ -289,14 +300,14 @@ dimension measured side by side.
|
|||
glass and progressive blur tuned side by side; the last
|
||||
text details (right-to-left list markers) and auto layout details (grid `fr` rows around
|
||||
fixed-size children).
|
||||
- **Then:** interop depth (6: .fig best effort), plugins and multiplayer (8; comments and version
|
||||
history are in).
|
||||
- **Then:** interop depth (6: .fig best effort), multiplayer (8; comments, version history and
|
||||
plugins are in).
|
||||
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
|
||||
|
||||
## Shipped so far
|
||||
|
||||
- **Architecture:** 13 crates with enforced layering (`cargo xtask layers`): geom, color, doc,
|
||||
layout, text, pathops, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action
|
||||
- **Architecture:** 14 crates with enforced layering (`cargo xtask layers`): geom, color, doc,
|
||||
layout, text, pathops, plugin, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action
|
||||
is a command (~330); undo is unlimited via structural sharing (`Arc` node snapshots); gestures are
|
||||
one undo step; `command.batch` runs several commands as one step.
|
||||
- **Document model:** pages, frames, groups, sections, rectangles, ellipses (arcs/rings), polygons,
|
||||
|
|
@ -371,7 +382,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
|||
| Prototyping | 6 | 91% | QA against larger real-world prototypes | 1–2 |
|
||||
| Import/export | 6 | 71% | .fig import (best effort), SVG filters beyond shadows and blurs and rotated repeating patterns, a CMYK output intent for print PDFs (needs a CMYK ICC profile) | 8–16 |
|
||||
| Dev mode / inspect | 2 | 91% | visual QA of the compare and focus views, comparing against an older named version (not only the snapshot taken when a frame first changes) | 1–2 |
|
||||
| Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 |
|
||||
| Comments, versions, misc | 3 | 62% | multiplayer; plugin-defined UI at run time, plugin settings storage | 6–11 |
|
||||
|
||||
Weighted feature depth ≈ **79%** (self-graded, from the table).
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ figcraft-render = { workspace = true }
|
|||
figcraft-format = { workspace = true }
|
||||
figcraft-svg = { workspace = true }
|
||||
figcraft-pdf = { workspace = true }
|
||||
figcraft-plugin = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
web-time = "1.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ pub static MENU: &[MenuItem] = &[
|
|||
// Plugins
|
||||
m!("Plugins", "Run last plugin", "plugins.runLast"),
|
||||
m!("Plugins", "Manage plugins…", "plugins.manage"),
|
||||
m!("Plugins/Development", "Import plugin from manifest…", "plugin.import"),
|
||||
// Preferences
|
||||
m!("Preferences", "Snap to geometry", "view.snapGeometry"),
|
||||
m!("Preferences", "Snap to objects", "view.snapObjects"),
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ fn registry() -> &'static Registry {
|
|||
list.extend(crate::component_codegen::specs());
|
||||
list.extend(crate::annotations::specs());
|
||||
list.extend(crate::distance::specs());
|
||||
list.extend(crate::plugins::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,8 @@ pub static KEYS: &[Binding] = &[
|
|||
k(Essential, "file.saveVersion", &["Alt+Cmd+S"]),
|
||||
k(Essential, "file.close", &["Cmd+W"]),
|
||||
k(Essential, "edit.findReplace", &["Cmd+F"]),
|
||||
// Ctrl+Alt+P is Pixel preview on Windows and Linux here.
|
||||
k(Essential, "plugins.runLast", &["Alt+Cmd+P"]).win(&["Ctrl+Alt+Shift+P"]),
|
||||
// Tools
|
||||
k(Tools, "tool.move", &["V"]),
|
||||
k(Tools, "tool.scale", &["K"]),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub mod keymap;
|
|||
pub mod library;
|
||||
pub mod measure;
|
||||
pub mod params;
|
||||
pub mod plugins;
|
||||
pub mod proto_run;
|
||||
pub mod shapebuilder;
|
||||
pub mod snap;
|
||||
|
|
@ -438,6 +439,9 @@ pub struct Prefs {
|
|||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// Installed plugins (Plugins menu), in menu order.
|
||||
#[serde(serialize_with = "plugins::persisted")]
|
||||
pub plugins: Vec<plugins::InstalledPlugin>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -453,6 +457,7 @@ impl Default for Prefs {
|
|||
theme: "light".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
plugins: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -499,6 +504,8 @@ pub struct Session {
|
|||
pub message: Option<String>,
|
||||
/// Library files read for the open documents.
|
||||
pub libraries: library::Cache,
|
||||
/// Plugin modules loaded in this session and the last plugin run.
|
||||
pub plugins: plugins::Runtime,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -529,6 +536,7 @@ impl Session {
|
|||
last_command: None,
|
||||
message: None,
|
||||
libraries: library::Cache::default(),
|
||||
plugins: plugins::Runtime::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -780,30 +788,32 @@ impl Session {
|
|||
|
||||
/// Run several commands as one undo step.
|
||||
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
|
||||
let before = self.ds()?.doc.clone();
|
||||
let sel = self.selection();
|
||||
let page = self.page()?;
|
||||
let mut out = Vec::new();
|
||||
let saved_len = self.ds()?.history.undo.len();
|
||||
for (id, p) in cmds {
|
||||
match self.execute(id, p) {
|
||||
Ok(v) => out.push(v),
|
||||
Err(e) => {
|
||||
let ds = self.ds_mut()?;
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect())
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the document, selection, page and history are put back.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let ds = self.ds_mut()?;
|
||||
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
let limit = std::mem::replace(&mut ds.history.limit, usize::MAX);
|
||||
let result = f(self);
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result };
|
||||
ds.history.limit = limit;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
if result.is_err() {
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.page = page;
|
||||
} else if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
ds.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: sel, page });
|
||||
if ds.history.undo.len() > limit.max(1) {
|
||||
ds.history.undo.remove(0);
|
||||
}
|
||||
ds.history.redo.clear();
|
||||
}
|
||||
Ok(out)
|
||||
result
|
||||
}
|
||||
|
||||
/// A summary for status displays.
|
||||
|
|
@ -1099,6 +1109,8 @@ mod pdf_print_tests;
|
|||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_conditional_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
//! Plugins: WebAssembly modules that act on the document through the same commands as the menus,
|
||||
//! CLI and MCP server, inside the sandbox of `figcraft-plugin`. This module is the policy: which
|
||||
//! 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, and the installed-plugin list kept in
|
||||
//! the preferences. See `docs/plugins.md`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_plugin::{Limits, PluginError};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::{CommandSpec, find_command};
|
||||
use crate::params::{get, num, string};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Param, ParamKind};
|
||||
|
||||
/// An installed plugin (in the preferences).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstalledPlugin {
|
||||
pub manifest: Manifest,
|
||||
/// Its manifest file; `None` when it was imported without one (its module then lives in this
|
||||
/// session only and isn't saved with the preferences).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Saves only the plugins imported from a manifest file (the others can't be loaded again).
|
||||
pub(crate) fn persisted<S: Serializer>(list: &[InstalledPlugin], s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.collect_seq(list.iter().filter(|p| p.path.is_some()))
|
||||
}
|
||||
|
||||
/// Per-session plugin state.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Runtime {
|
||||
/// Modules of plugins imported without a manifest file, by plugin id.
|
||||
modules: HashMap<String, Arc<Vec<u8>>>,
|
||||
/// The last run (plugin id, command id, params): "Run last plugin".
|
||||
pub last: Option<(String, String, Value)>,
|
||||
}
|
||||
|
||||
/// Command prefixes no plugin may call, whatever its permissions: files and the app (plugins get
|
||||
/// no file system), other documents, version history, the undo history, plugins themselves and
|
||||
/// nested batches (a run already is one undo step).
|
||||
const DENIED: &[&str] = &[
|
||||
"file.",
|
||||
"doc.",
|
||||
"version.",
|
||||
"app.",
|
||||
"pref.",
|
||||
"help.",
|
||||
"plugin.",
|
||||
"plugins.",
|
||||
"libraries.",
|
||||
"library.add",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"edit.repeat",
|
||||
"command.batch",
|
||||
"view.theme",
|
||||
];
|
||||
/// Read-only exceptions to [`DENIED`].
|
||||
const ALLOWED: &[&str] = &["doc.list", "version.list"];
|
||||
|
||||
/// Why a plugin may not call `command` with `params` (before running it), if it may not.
|
||||
fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
|
||||
return Some(format!("`{command}` isn't available to plugins"));
|
||||
}
|
||||
if command == "image.place" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the image as `data`".into());
|
||||
}
|
||||
if !m.permissions.allows(command) {
|
||||
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
|
||||
}
|
||||
if m.permissions.access == Access::Read && find_command(command).is_some_and(|c| c.history) {
|
||||
return Some(format!("`{command}` edits the document, and {} may only read it", m.name));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The sandbox's view of the session: commands go through [`refusal`] and run as nested groups,
|
||||
/// so a refused one leaves nothing behind.
|
||||
struct EngineHost<'s> {
|
||||
s: &'s mut Session,
|
||||
manifest: &'s Manifest,
|
||||
}
|
||||
|
||||
impl figcraft_plugin::Host for EngineHost<'_> {
|
||||
fn call(&mut self, command: &str, params: &str) -> std::result::Result<String, String> {
|
||||
let params: Value = serde_json::from_str(params).map_err(|e| format!("params for `{command}` aren't JSON: {e}"))?;
|
||||
if let Some(why) = refusal(self.manifest, command, ¶ms) {
|
||||
return Err(why);
|
||||
}
|
||||
let read_only = self.manifest.permissions.access == Access::Read;
|
||||
let ui_before = self.s.ui_requests.len();
|
||||
let r = self.s.grouped(command, |s| {
|
||||
let doc = s.ds()?.doc.clone();
|
||||
let v = s.execute(command, ¶ms)?;
|
||||
let opened_ui = s.ui_requests.len() > ui_before;
|
||||
s.ui_requests.truncate(ui_before);
|
||||
if opened_ui {
|
||||
return Err(EngineError::Disabled(command.into(), "it opens a dialog or panel, which plugins can't".into()));
|
||||
}
|
||||
if read_only && !Arc::ptr_eq(&doc, &s.ds()?.doc) {
|
||||
return Err(EngineError::Disabled(command.into(), "it changed the document, and this plugin may only read it".into()));
|
||||
}
|
||||
Ok(v)
|
||||
});
|
||||
r.map(|v| v.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn selection(&mut self) -> String {
|
||||
json!(self.s.selection()).to_string()
|
||||
}
|
||||
|
||||
fn notify(&mut self, message: &str) {
|
||||
self.s.message = Some(message.to_string());
|
||||
}
|
||||
|
||||
fn log(&mut self, line: &str) {
|
||||
log::info!("plugin {}: {line}", self.manifest.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed plugin `id`.
|
||||
fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a InstalledPlugin> {
|
||||
s.prefs.plugins.iter().find(|p| p.manifest.id == id).ok_or_else(|| bad(cmd, format!("no plugin `{id}` is installed")))
|
||||
}
|
||||
|
||||
/// Read a file of at most `max` bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
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 {
|
||||
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
|
||||
}
|
||||
|
||||
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
|
||||
/// path and its module.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
|
||||
let text = String::from_utf8(text).map_err(|_| bad(cmd, "the manifest isn't UTF-8 text"))?;
|
||||
let manifest = Manifest::parse(&text).map_err(|e| bad(cmd, e))?;
|
||||
let wasm = module_file(&file, &manifest)?;
|
||||
Ok((manifest, file.to_string_lossy().into_owned(), wasm))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_from_disk(cmd: &str, _path: &str) -> Result<(Manifest, String, Vec<u8>)> {
|
||||
Err(bad(cmd, "there are no files in the browser: pass `manifest` and `wasm` (base64)"))
|
||||
}
|
||||
|
||||
/// The module a manifest at `manifest_file` names.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn module_file(manifest_file: &std::path::Path, m: &Manifest) -> Result<Vec<u8>> {
|
||||
let dir = manifest_file.parent().unwrap_or(std::path::Path::new("."));
|
||||
read_file(&dir.join(&m.main), Limits::default().module_bytes)
|
||||
}
|
||||
|
||||
/// The module of an installed plugin: from the session, else read again from disk (so a rebuilt
|
||||
/// development plugin runs its new code).
|
||||
fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>> {
|
||||
if let Some(m) = s.plugins.modules.get(&p.manifest.id) {
|
||||
return Ok(m.clone());
|
||||
}
|
||||
let Some(path) = &p.path else {
|
||||
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
|
||||
}
|
||||
|
||||
fn plugin_error(name: &str, e: PluginError) -> EngineError {
|
||||
EngineError::Other(format!("{name}: {e}"))
|
||||
}
|
||||
|
||||
/// A plugin as `plugin.list` reports it.
|
||||
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["loaded"] = json!(p.path.is_some() || s.plugins.modules.contains_key(&p.manifest.id));
|
||||
v
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let plugins: Vec<Value> = s.prefs.plugins.iter().map(|p| describe(s, p)).collect();
|
||||
let last = s.plugins.last.as_ref().map(|(id, command, _)| json!({"id": id, "command": command}));
|
||||
Ok(json!({"plugins": plugins, "last": last}))
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plugin.import";
|
||||
let (manifest, path, wasm) = match (string(p, "path"), get(p, "manifest")) {
|
||||
(Some(path), _) => {
|
||||
let (m, path, wasm) = load_from_disk(cmd, &path)?;
|
||||
(m, Some(path), wasm)
|
||||
}
|
||||
(None, Some(m)) => {
|
||||
let m = match m {
|
||||
Value::String(text) => Manifest::parse(text),
|
||||
other => Manifest::from_value(other),
|
||||
}
|
||||
.map_err(|e| bad(cmd, e))?;
|
||||
use base64::Engine as _;
|
||||
let b64 = string(p, "wasm").ok_or_else(|| bad(cmd, "pass the module as `wasm` (base64) with `manifest`"))?;
|
||||
if b64.len() > Limits::default().module_bytes / 3 * 4 + 4 {
|
||||
return Err(bad(cmd, "the module is too large"));
|
||||
}
|
||||
let wasm = base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`wasm`: {e}")))?;
|
||||
(m, None, wasm)
|
||||
}
|
||||
(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() {
|
||||
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);
|
||||
let list = &mut s.prefs.plugins;
|
||||
let replaced = match list.iter_mut().find(|x| x.manifest.id == id) {
|
||||
Some(slot) => {
|
||||
*slot = entry;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
list.push(entry);
|
||||
false
|
||||
}
|
||||
};
|
||||
out["replaced"] = json!(replaced);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plugin.remove";
|
||||
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?;
|
||||
installed(s, cmd, &id)?;
|
||||
s.prefs.plugins.retain(|x| x.manifest.id != id);
|
||||
s.plugins.modules.remove(&id);
|
||||
if s.plugins.last.as_ref().is_some_and(|(last, ..)| *last == id) {
|
||||
s.plugins.last = None;
|
||||
}
|
||||
Ok(json!({"removed": id}))
|
||||
}
|
||||
|
||||
/// Run plugin `id`'s `command` with `params` as one undo step.
|
||||
pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fuel: Option<f64>) -> Result<Value> {
|
||||
let cmd = "plugin.run";
|
||||
let mut p = installed(s, cmd, id)?.clone();
|
||||
// Checked again: the preferences file may have been edited by hand.
|
||||
p.manifest = Manifest::from_value(&json!(p.manifest)).map_err(|e| bad(cmd, e))?;
|
||||
let m = &p.manifest;
|
||||
let c = m.command(command).ok_or_else(|| bad(cmd, format!("{} has no command `{}`", m.name, command.unwrap_or_default())))?;
|
||||
let params = c.resolve_params(params).map_err(|e| bad(cmd, e))?;
|
||||
let wasm = module_of(s, &p)?;
|
||||
let mut limits = Limits::default();
|
||||
if let Some(f) = fuel {
|
||||
limits.fuel = (f.max(1000.0) as u64).min(limits.fuel);
|
||||
}
|
||||
let input = json!({"api": figcraft_plugin::API_VERSION, "command": c.id, "params": params, "selection": s.selection()}).to_string();
|
||||
s.plugins.last = Some((m.id.clone(), c.id.clone(), params));
|
||||
let label = if m.commands.len() > 1 { format!("{}: {}", m.name, c.label) } else { m.name.clone() };
|
||||
let out = s.grouped(&label, |s| {
|
||||
let mut host = EngineHost { s, manifest: m };
|
||||
figcraft_plugin::run(&wasm, &input, &limits, &mut host).map_err(|e| plugin_error(&m.name, e))
|
||||
})?;
|
||||
let output = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
|
||||
Ok(json!({"plugin": m.id, "command": c.id, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}))
|
||||
}
|
||||
|
||||
fn run_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = string(p, "id").ok_or_else(|| bad("plugin.run", "`id` required (see plugin.list)"))?;
|
||||
let command = string(p, "command");
|
||||
run(s, &id, command.as_deref(), get(p, "params").unwrap_or(&Value::Null), num(p, "fuel"))
|
||||
}
|
||||
|
||||
fn run_last(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some((id, command, params)) = s.plugins.last.clone() else {
|
||||
return Err(EngineError::Disabled("plugins.runLast".into(), "no plugin has run yet".into()));
|
||||
};
|
||||
run(s, &id, Some(&command), ¶ms, None)
|
||||
}
|
||||
|
||||
fn manage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.request_ui("dialog.plugins", json!({}));
|
||||
list(s, p)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("plugin.list", "List plugins", "{} → {plugins: [{id, name, version, main, permissions, commands, path, loaded}], last}", false, list),
|
||||
spec!(
|
||||
"plugin.import",
|
||||
"Import plugin from manifest…",
|
||||
"{path (figcraft-plugin.json or its folder) | manifest (object or JSON text), wasm (base64)} replaces a plugin with the same id",
|
||||
false,
|
||||
import
|
||||
),
|
||||
spec!("plugin.remove", "Remove plugin", "{id}", false, remove),
|
||||
spec!(
|
||||
"plugin.run",
|
||||
"Run plugin",
|
||||
"{id, command? (default: the first), params? (the command's parameters), fuel? (lower instruction budget)} one undo step → {output, logs, calls, fuel}",
|
||||
false,
|
||||
run_cmd
|
||||
),
|
||||
spec!("plugins.runLast", "Run last plugin", "{}", false, run_last),
|
||||
spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
//! Plugins: runs are one undo step and roll back when they fail, permissions and the host's own
|
||||
//! limits are enforced, fuel and memory are capped, and junk never gets in. Test plugins are
|
||||
//! assembled from WAT text: each makes a fixed list of command calls.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::components::tests::{ex, idv, node};
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
/// What a test plugin does when a call is refused: fail the run (status 1), or carry on and report
|
||||
/// the refusal as its output.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum OnRefusal {
|
||||
Fail,
|
||||
Report,
|
||||
}
|
||||
|
||||
/// `s` as a WAT string literal.
|
||||
fn lit(s: &str) -> String {
|
||||
s.bytes()
|
||||
.map(|b| if b.is_ascii_graphic() && b != b'"' && b != b'\\' || b == b' ' { (b as char).to_string() } else { format!("\\{b:02x}") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A module that reads the selection, makes `calls` in order and outputs the selection it read
|
||||
/// (or the first refusal). `tail` runs after the calls (an endless loop, a trap…).
|
||||
fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
let mut data = String::new();
|
||||
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
|
||||
let mut at = 0usize;
|
||||
for (cmd, params) in calls {
|
||||
let p = params.to_string();
|
||||
data += &format!("(data (i32.const {at}) \"{}\") (data (i32.const {}) \"{}\")\n", lit(cmd), at + cmd.len(), lit(&p));
|
||||
let status = if on_refusal == OnRefusal::Fail { 1 } else { 0 };
|
||||
body += &format!(
|
||||
"(if (call $call (i32.const {at}) (i32.const {}) (i32.const {}) (i32.const {}))
|
||||
(then (call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const {status}))))\n",
|
||||
cmd.len(),
|
||||
at + cmd.len(),
|
||||
p.len()
|
||||
);
|
||||
at += cmd.len() + p.len();
|
||||
}
|
||||
assert!(at < 40000);
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "selection" (func $selection (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "notify" (func $notify (param i32 i32)))
|
||||
(memory (export "memory") 2)
|
||||
{data}
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) (local $sel i32)
|
||||
{body}
|
||||
{tail}
|
||||
(call $set_output (i32.const 60000) (local.get $sel))
|
||||
(i32.const 0)))"#
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn manifest(id: &str, access: &str, allow: &[&str]) -> Value {
|
||||
json!({"id": id, "name": format!("Plugin {id}"), "version": "1.0.0", "main": "plugin.wasm",
|
||||
"permissions": {"access": access, "commands": allow}})
|
||||
}
|
||||
|
||||
/// Install a plugin from a manifest and module bytes (no files).
|
||||
fn install(s: &mut Session, manifest: Value, wasm: &[u8]) {
|
||||
ex(s, "plugin.import", json!({"manifest": manifest, "wasm": b64(wasm)}));
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 10, "height": 10})))
|
||||
}
|
||||
|
||||
fn layer_count(s: &Session) -> usize {
|
||||
let ds = s.ds().unwrap();
|
||||
ds.doc.node(ds.page).unwrap().children.len()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn run_err(s: &mut Session, id: &str) -> String {
|
||||
match s.execute("plugin.run", &json!({"id": id, "fuel": 2_000_000})) {
|
||||
Ok(v) => panic!("ran: {v}"),
|
||||
Err(e) => {
|
||||
assert!(!matches!(e, EngineError::Internal { .. }), "{e}");
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let calls = [
|
||||
("object.rename", json!({"name": "Tidy"})),
|
||||
("shape.rectangle", json!({"x": 50, "y": 0, "width": 20, "height": 20})),
|
||||
("node.get", json!({"id": a})),
|
||||
];
|
||||
install(&mut s, manifest("org.test.tidy", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
|
||||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
|
||||
assert_eq!(out["output"], json!([a.0]), "the plugin read the selection");
|
||||
assert_eq!(out["calls"], json!(4));
|
||||
assert_eq!(node(&s, a).name, "Tidy");
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
|
||||
assert_eq!(s.ds().unwrap().history.undo.last().unwrap().label, "Plugin org.test.tidy");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
assert_eq!(layer_count(&s), layers);
|
||||
// Run last plugin runs it again.
|
||||
ex(&mut s, "plugins.runLast", json!({}));
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(ex(&mut s, "plugin.list", json!({}))["last"], json!({"id": "org.test.tidy", "command": "run"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_plugins_and_allowlists_are_enforced() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
// Read access: queries run, edits are refused and the run fails, leaving nothing.
|
||||
install(
|
||||
&mut s,
|
||||
manifest("org.test.reader", "read", &[]),
|
||||
&script(&[("node.get", json!({"id": a})), ("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))], OnRefusal::Fail, ""),
|
||||
);
|
||||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
let e = run_err(&mut s, "org.test.reader");
|
||||
assert!(e.contains("may only read"), "{e}");
|
||||
assert_eq!((layer_count(&s), undo_len(&s)), (layers, undo));
|
||||
// Write access limited to some commands.
|
||||
install(
|
||||
&mut s,
|
||||
manifest("org.test.narrow", "write", &["node.*", "select.set"]),
|
||||
&script(&[("node.get", json!({"id": a})), ("object.rename", json!({"name": "x"}))], OnRefusal::Fail, ""),
|
||||
);
|
||||
let e = run_err(&mut s, "org.test.narrow");
|
||||
assert!(e.contains("asked permission for"), "{e}");
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_dialogs_history_and_plugins_are_off_limits() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-plugin-deny-{}", std::process::id()));
|
||||
let target = dir.join("stolen.figcraft").to_string_lossy().into_owned();
|
||||
let refused = [
|
||||
("file.saveAs", json!({"path": target})),
|
||||
("file.open", json!({"path": "C:/Windows/win.ini"})),
|
||||
("image.place", json!({"path": "secret.png"})),
|
||||
("library.add", json!({"path": "lib.figcraft"})),
|
||||
("plugin.run", json!({"id": "org.test.deny"})),
|
||||
("plugin.remove", json!({"id": "org.test.deny"})),
|
||||
("edit.undo", json!({})),
|
||||
("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})),
|
||||
("doc.switch", json!({"index": 0})),
|
||||
("pref.keepTool", json!({})),
|
||||
// Without a name it would open inline renaming in the layers panel: a UI request.
|
||||
("object.rename", json!({})),
|
||||
];
|
||||
for (cmd, params) in refused {
|
||||
install(&mut s, manifest("org.test.deny", "write", &[]), &script(&[(cmd, params.clone())], OnRefusal::Report, ""));
|
||||
let (doc, undo) = (s.ds().unwrap().doc.clone(), undo_len(&s));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.deny"}));
|
||||
let msg = out["output"].as_str().unwrap_or_default().to_string();
|
||||
assert!(msg.contains("plugins") || msg.contains("dialog"), "{cmd}: {out}");
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc), "{cmd} left the document as it was");
|
||||
assert_eq!(undo_len(&s), undo, "{cmd}");
|
||||
assert!(s.ui_requests.is_empty(), "{cmd}: {:?}", s.ui_requests);
|
||||
}
|
||||
assert!(!dir.exists(), "nothing was written");
|
||||
assert!(ex(&mut s, "plugin.list", json!({}))["plugins"].as_array().unwrap().len() == 1, "the plugin didn't remove itself");
|
||||
// The read-only exceptions are open.
|
||||
install(&mut s, manifest("org.test.reads", "read", &[]), &script(&[("doc.list", json!({})), ("version.list", json!({}))], OnRefusal::Fail, ""));
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.reads"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traps_endless_loops_and_memory_hogs_roll_back() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))];
|
||||
let cases = [
|
||||
("org.test.trap", "unreachable", "crashed"),
|
||||
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
|
||||
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
|
||||
];
|
||||
for (id, tail, want) in cases {
|
||||
install(&mut s, manifest(id, "write", &[]), &script(&edit, OnRefusal::Fail, tail));
|
||||
let (layers, undo, sel) = (layer_count(&s), undo_len(&s), s.selection());
|
||||
let started = web_time::Instant::now();
|
||||
let e = run_err(&mut s, id);
|
||||
assert!(e.contains(want), "{id}: {e}");
|
||||
assert!(started.elapsed().as_secs() < 20, "{id} was stopped promptly");
|
||||
assert_eq!((layer_count(&s), undo_len(&s), s.selection()), (layers, undo, sel), "{id} rolled back");
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
// A module that asks for more memory than the cap up front never starts.
|
||||
let hog =
|
||||
wat::parse_str(r#"(module (memory (export "memory") 3000) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#).unwrap();
|
||||
install(&mut s, manifest("org.test.hog", "write", &[]), &hog);
|
||||
assert!(run_err(&mut s, "org.test.hog").contains("memory limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_defaults_and_notifications_reach_the_plugin() {
|
||||
let mut s = Session::new();
|
||||
let echo = wat::parse_str(
|
||||
r#"(module
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "notify" (func $notify (param i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "Done!")
|
||||
(func (export "figcraft_run") (param $n i32) (result i32)
|
||||
(call $set_output (i32.const 100) (call $reply_read (i32.const 100) (local.get $n)))
|
||||
(call $notify (i32.const 0) (i32.const 5))
|
||||
(i32.const 0)))"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = manifest("org.test.echo", "read", &[]);
|
||||
m["commands"] = json!([
|
||||
{"id": "first", "label": "First"},
|
||||
{"id": "grid", "label": "Swatch grid", "params": [{"name": "columns", "type": "number", "default": 4, "min": 1, "max": 8}, {"name": "label", "type": "text"}]}
|
||||
]);
|
||||
install(&mut s, m, &echo);
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.echo", "command": "grid", "params": {"columns": 99}}));
|
||||
assert_eq!(out["output"], json!({"api": 1, "command": "grid", "params": {"columns": 8.0, "label": ""}, "selection": []}));
|
||||
assert_eq!(s.message.as_deref(), Some("Done!"));
|
||||
assert_eq!(undo_len(&s), 0, "a run that edits nothing adds no undo step");
|
||||
assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.echo"}))["output"]["command"], "first", "the first command by default");
|
||||
for bad in [
|
||||
json!({"id": "org.test.echo", "command": "nope"}),
|
||||
json!({"id": "org.test.echo", "command": "grid", "params": {"columns": "many"}}),
|
||||
json!({"id": "missing"}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(matches!(s.execute("plugin.run", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
assert!(s.execute("plugins.runLast", &json!({})).is_ok());
|
||||
ex(&mut s, "plugin.remove", json!({"id": "org.test.echo"}));
|
||||
assert!(s.execute("plugins.runLast", &json!({})).is_err(), "the removed plugin is forgotten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_manifests_and_modules_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let ok = script(&[], OnRefusal::Fail, "");
|
||||
let junk = [
|
||||
json!({}),
|
||||
json!({"manifest": {}}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": "!!!not base64"}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": b64(b"\0asm\x01\0\0\0garbage")}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": b64(b"")}),
|
||||
json!({"manifest": manifest("org.test.x", "everything", &[]), "wasm": b64(&ok)}),
|
||||
json!({"manifest": manifest("../escape", "write", &[]), "wasm": b64(&ok)}),
|
||||
json!({"manifest": "{ not json", "wasm": b64(&ok)}),
|
||||
json!({"manifest": 7, "wasm": b64(&ok)}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[])}),
|
||||
json!({"path": "Z:/definitely/not/here/figcraft-plugin.json"}),
|
||||
json!({"path": 5}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": "A".repeat(23_000_000)}),
|
||||
];
|
||||
for j in &junk {
|
||||
match s.execute("plugin.import", j) {
|
||||
Ok(v) => panic!("imported {v}"),
|
||||
Err(EngineError::Internal { msg, .. }) => panic!("panicked: {msg}"),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(ex(&mut s, "plugin.list", json!({}))["plugins"], json!([]));
|
||||
// Every plugin command with junk parameters (the engine-wide sweep runs them too).
|
||||
install(&mut s, manifest("org.test.ok", "write", &[]), &ok);
|
||||
for cmd in ["plugin.list", "plugin.import", "plugin.remove", "plugin.run", "plugins.runLast", "plugins.manage"] {
|
||||
for j in [
|
||||
json!(null),
|
||||
json!([1]),
|
||||
json!({"id": 5, "command": [], "params": "x", "fuel": "NaN"}),
|
||||
json!({"id": "org.test.ok", "params": [1], "fuel": -1}),
|
||||
] {
|
||||
if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd, &j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A temporary folder removed when dropped (also when a test fails).
|
||||
struct TempDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_plugins_load_from_a_manifest_and_reload_their_module() {
|
||||
let dir = TempDir(std::env::temp_dir().join(format!("figcraft-plugin-dev-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let write_module = |calls: &[(&str, Value)]| std::fs::write(dir.0.join("plugin.wasm"), script(calls, OnRefusal::Fail, "")).unwrap();
|
||||
std::fs::write(dir.0.join("figcraft-plugin.json"), manifest("org.test.dev", "write", &[]).to_string()).unwrap();
|
||||
write_module(&[("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))]);
|
||||
let mut s = Session::new();
|
||||
// The folder or the manifest file itself.
|
||||
let out = ex(&mut s, "plugin.import", json!({"path": dir.0.to_string_lossy()}));
|
||||
assert_eq!(out["id"], "org.test.dev");
|
||||
assert_eq!(out["replaced"], false);
|
||||
let manifest_path = dir.0.join("figcraft-plugin.json").to_string_lossy().into_owned();
|
||||
assert_eq!(ex(&mut s, "plugin.import", json!({"path": manifest_path}))["replaced"], true);
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.dev"}));
|
||||
assert_eq!(layer_count(&s), 1);
|
||||
// A rebuilt module runs without importing again.
|
||||
write_module(&[
|
||||
("shape.ellipse", json!({"x": 0, "y": 0, "width": 5, "height": 5})),
|
||||
("shape.ellipse", json!({"x": 9, "y": 0, "width": 5, "height": 5})),
|
||||
]);
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.dev"}));
|
||||
assert_eq!(layer_count(&s), 3);
|
||||
// Only plugins with a manifest file are saved with the preferences.
|
||||
install(&mut s, manifest("org.test.memory", "read", &[]), &script(&[], OnRefusal::Fail, ""));
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
let ids: Vec<&str> = saved["plugins"].as_array().unwrap().iter().filter_map(|p| p["manifest"]["id"].as_str()).collect();
|
||||
assert_eq!(ids, vec!["org.test.dev"]);
|
||||
let back: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(back.plugins.len(), 1);
|
||||
// A missing module fails the run cleanly.
|
||||
std::fs::remove_file(dir.0.join("plugin.wasm")).unwrap();
|
||||
assert!(run_err(&mut s, "org.test.dev").contains("plugin.wasm"));
|
||||
ex(&mut s, "plugin.remove", json!({"id": "org.test.dev"}));
|
||||
assert_eq!(ex(&mut s, "plugin.list", json!({}))["plugins"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() {
|
||||
let mut s = Session::new();
|
||||
s.ds_mut().unwrap().history.limit = 3;
|
||||
let first = rect(&mut s, 0.0);
|
||||
for x in [20.0, 40.0] {
|
||||
rect(&mut s, x);
|
||||
}
|
||||
assert_eq!(undo_len(&s), 3);
|
||||
let edits: Vec<(&str, Value)> = (0..5).map(|i| ("shape.ellipse", json!({"x": i * 10, "y": 50, "width": 5, "height": 5}))).collect();
|
||||
install(&mut s, manifest("org.test.many", "write", &[]), &script(&edits, OnRefusal::Fail, ""));
|
||||
let oldest = s.ds().unwrap().history.undo[1].doc.clone();
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.many"}));
|
||||
let undo = &s.ds().unwrap().history.undo;
|
||||
assert_eq!(undo.len(), 3, "the run is one step; only the oldest step made room for it");
|
||||
assert!(std::sync::Arc::ptr_eq(&undo[0].doc, &oldest));
|
||||
assert!(s.ds().unwrap().doc.contains(first));
|
||||
// A failing run leaves the history alone.
|
||||
install(&mut s, manifest("org.test.fail", "write", &[]), &script(&edits, OnRefusal::Fail, "unreachable"));
|
||||
let before: Vec<_> = s.ds().unwrap().history.undo.iter().map(|e| e.doc.clone()).collect();
|
||||
run_err(&mut s, "org.test.fail");
|
||||
let after = &s.ds().unwrap().history.undo;
|
||||
assert!(before.len() == after.len() && before.iter().zip(after).all(|(a, b)| std::sync::Arc::ptr_eq(a, &b.doc)));
|
||||
// A manifest edited in the preferences file is checked again before it runs.
|
||||
s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into();
|
||||
assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "figcraft-plugin"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "FigCraft plugins: the plugin manifest and the WebAssembly sandbox plugins run in (a metered interpreter with no file system, network or clock)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
wasmi = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
//! FigCraft plugins: the manifest (`figcraft-plugin.json`) and the WebAssembly sandbox a plugin
|
||||
//! runs in. The engine decides what a plugin may do (its permissions, which commands it may call,
|
||||
//! one undo step per run); 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 manifest;
|
||||
mod sandbox;
|
||||
|
||||
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Param, ParamKind, Permissions, PluginCommand};
|
||||
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Limits, PluginError, RunOutput, check_module, run};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
//! The plugin manifest, `figcraft-plugin.json`: who the plugin is, which module to run, the menu
|
||||
//! commands it adds (with their parameters) and the permissions it asks for.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// The manifest's file name.
|
||||
pub const MANIFEST_FILE: &str = "figcraft-plugin.json";
|
||||
/// The plugin API version this build implements (`api` in the manifest).
|
||||
pub const API_VERSION: u32 = 1;
|
||||
/// Largest manifest read.
|
||||
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
|
||||
|
||||
const MAX_ID: usize = 128;
|
||||
const MAX_NAME: usize = 64;
|
||||
const MAX_LABEL: usize = 80;
|
||||
const MAX_TEXT: usize = 1000;
|
||||
const MAX_COMMANDS: usize = 64;
|
||||
const MAX_PARAMS: usize = 32;
|
||||
const MAX_OPTIONS: usize = 64;
|
||||
const MAX_ALLOW: usize = 256;
|
||||
/// Longest text parameter value.
|
||||
const MAX_TEXT_VALUE: usize = 10_000;
|
||||
|
||||
/// A parsed, validated manifest.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
/// Stable id, e.g. `org.example.tidy-names` (letters, digits, `.`, `-`, `_`).
|
||||
pub id: String,
|
||||
/// Shown in the Plugins menu.
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
|
||||
pub main: String,
|
||||
/// The plugin API version the plugin was written for.
|
||||
#[serde(default = "api_version")]
|
||||
pub api: u32,
|
||||
#[serde(default)]
|
||||
pub permissions: Permissions,
|
||||
/// Menu commands; a manifest without any gets one, `run`.
|
||||
#[serde(default)]
|
||||
pub commands: Vec<PluginCommand>,
|
||||
}
|
||||
|
||||
fn api_version() -> u32 {
|
||||
API_VERSION
|
||||
}
|
||||
|
||||
/// What a plugin may do.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Permissions {
|
||||
/// `read`: commands that leave the document as it is; `write`: edits too.
|
||||
#[serde(default)]
|
||||
pub access: Access,
|
||||
/// The commands the plugin may call: exact ids or prefixes ending in `*` (`node.*`). Empty: any
|
||||
/// command its access allows.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub commands: Vec<String>,
|
||||
}
|
||||
|
||||
impl Permissions {
|
||||
/// Whether the allowlist admits `command`.
|
||||
pub fn allows(&self, command: &str) -> bool {
|
||||
self.commands.is_empty()
|
||||
|| self.commands.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => command.starts_with(prefix),
|
||||
None => p == command,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only or read-write document access.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Access {
|
||||
#[default]
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// One menu command of a plugin.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PluginCommand {
|
||||
/// Passed to the plugin as `command`.
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// Parameters asked for before the command runs (a small form in the app).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub params: Vec<Param>,
|
||||
}
|
||||
|
||||
/// A command parameter: a form field in the app, a key of `params` everywhere.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Param {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub label: String,
|
||||
#[serde(rename = "type", default)]
|
||||
pub kind: ParamKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<Value>,
|
||||
/// The choices of a `select`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
/// Bounds of a `number`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub min: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<f64>,
|
||||
}
|
||||
|
||||
/// The kind of value a parameter takes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ParamKind {
|
||||
#[default]
|
||||
Text,
|
||||
Number,
|
||||
Boolean,
|
||||
Select,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
/// The label shown in the form.
|
||||
pub fn title(&self) -> &str {
|
||||
if self.label.is_empty() { &self.name } else { &self.label }
|
||||
}
|
||||
|
||||
/// The value used when none is given.
|
||||
pub fn default_value(&self) -> Value {
|
||||
if let Some(d) = self.default.as_ref().and_then(|d| self.coerce(d).ok()) {
|
||||
return d;
|
||||
}
|
||||
match self.kind {
|
||||
ParamKind::Text => Value::String(String::new()),
|
||||
ParamKind::Number => serde_json::json!(self.clamp(0.0)),
|
||||
ParamKind::Boolean => Value::Bool(false),
|
||||
ParamKind::Select => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as this parameter's kind, or why it can't be.
|
||||
pub fn coerce(&self, v: &Value) -> Result<Value, String> {
|
||||
let name = &self.name;
|
||||
match self.kind {
|
||||
ParamKind::Text => match v {
|
||||
Value::String(s) if s.chars().count() <= MAX_TEXT_VALUE => Ok(v.clone()),
|
||||
Value::String(_) => Err(format!("`{name}` is longer than {MAX_TEXT_VALUE} characters")),
|
||||
Value::Number(n) => Ok(Value::String(n.to_string())),
|
||||
_ => Err(format!("`{name}` must be text")),
|
||||
},
|
||||
ParamKind::Number => {
|
||||
let n = match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(s) => s.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|n| n.is_finite())
|
||||
.ok_or_else(|| format!("`{name}` must be a number"))?;
|
||||
Ok(serde_json::json!(self.clamp(n)))
|
||||
}
|
||||
ParamKind::Boolean => v.as_bool().map(Value::Bool).ok_or_else(|| format!("`{name}` must be true or false")),
|
||||
ParamKind::Select => match v.as_str() {
|
||||
Some(s) if self.options.iter().any(|o| o == s) => Ok(v.clone()),
|
||||
_ => Err(format!("`{name}` must be one of: {}", self.options.join(", "))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` within the number's bounds.
|
||||
fn clamp(&self, n: f64) -> f64 {
|
||||
n.max(self.min.unwrap_or(f64::MIN)).min(self.max.unwrap_or(f64::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginCommand {
|
||||
/// `given` with every declared parameter checked and the missing ones filled with their
|
||||
/// defaults. Keys the command doesn't declare pass through.
|
||||
pub fn resolve_params(&self, given: &Value) -> Result<Value, String> {
|
||||
let mut out = match given {
|
||||
Value::Object(m) => m.clone(),
|
||||
Value::Null => Map::new(),
|
||||
_ => return Err("`params` must be an object".into()),
|
||||
};
|
||||
for p in &self.params {
|
||||
let v = match out.get(&p.name) {
|
||||
Some(v) if !v.is_null() => p.coerce(v)?,
|
||||
_ => p.default_value(),
|
||||
};
|
||||
out.insert(p.name.clone(), v);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse and validate manifest JSON.
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
if text.len() > MAX_MANIFEST_BYTES {
|
||||
return Err(format!("the manifest is larger than {} KB", MAX_MANIFEST_BYTES / 1024));
|
||||
}
|
||||
let m: Manifest = serde_json::from_str(text).map_err(|e| format!("manifest: {e}"))?;
|
||||
m.validated()
|
||||
}
|
||||
|
||||
/// Validate a manifest given as JSON.
|
||||
pub fn from_value(v: &Value) -> Result<Manifest, String> {
|
||||
Manifest::parse(&v.to_string())
|
||||
}
|
||||
|
||||
/// The command `id`, or the first one when `id` is `None` or empty.
|
||||
pub fn command(&self, id: Option<&str>) -> Option<&PluginCommand> {
|
||||
match id.filter(|i| !i.is_empty()) {
|
||||
Some(id) => self.commands.iter().find(|c| c.id == id),
|
||||
None => self.commands.first(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check every field and fill in the defaults (a `run` command when there is none).
|
||||
fn validated(mut self) -> Result<Manifest, String> {
|
||||
check_id("id", &self.id, MAX_ID)?;
|
||||
self.name = clean("name", &self.name, MAX_NAME)?;
|
||||
if self.name.is_empty() {
|
||||
return Err("manifest: `name` is empty".into());
|
||||
}
|
||||
self.version = clean("version", &self.version, 32)?;
|
||||
self.description = clean("description", &self.description, MAX_TEXT)?;
|
||||
check_main(&self.main)?;
|
||||
if self.api == 0 || self.api > API_VERSION {
|
||||
return Err(format!("manifest: plugin API {} isn't supported (this FigCraft runs API {API_VERSION})", self.api));
|
||||
}
|
||||
if self.permissions.commands.len() > MAX_ALLOW {
|
||||
return Err(format!("manifest: more than {MAX_ALLOW} entries in `permissions.commands`"));
|
||||
}
|
||||
for p in &self.permissions.commands {
|
||||
let bare = p.strip_suffix('*').unwrap_or(p);
|
||||
if p.is_empty() || p.len() > MAX_ID || bare.contains('*') || !bare.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') {
|
||||
return Err(format!("manifest: `{}` in `permissions.commands` isn't a command id or `prefix.*`", truncate(p)));
|
||||
}
|
||||
}
|
||||
if self.commands.is_empty() {
|
||||
self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() });
|
||||
}
|
||||
if self.commands.len() > MAX_COMMANDS {
|
||||
return Err(format!("manifest: more than {MAX_COMMANDS} commands"));
|
||||
}
|
||||
if let Some(dup) = first_duplicate(self.commands.iter().map(|c| c.id.as_str())) {
|
||||
return Err(format!("manifest: two commands are called `{}`", truncate(dup)));
|
||||
}
|
||||
for c in &mut self.commands {
|
||||
check_id("command id", &c.id, MAX_NAME)?;
|
||||
c.label = clean("command label", &c.label, MAX_LABEL)?;
|
||||
if c.label.is_empty() {
|
||||
c.label = c.id.clone();
|
||||
}
|
||||
if c.params.len() > MAX_PARAMS {
|
||||
return Err(format!("manifest: command `{}` has more than {MAX_PARAMS} parameters", c.id));
|
||||
}
|
||||
for p in &mut c.params {
|
||||
check_id("parameter name", &p.name, MAX_NAME)?;
|
||||
p.label = clean("parameter label", &p.label, MAX_LABEL)?;
|
||||
if p.options.len() > MAX_OPTIONS {
|
||||
return Err(format!("manifest: parameter `{}` has more than {MAX_OPTIONS} options", p.name));
|
||||
}
|
||||
for o in &mut p.options {
|
||||
*o = clean("option", o, MAX_LABEL)?;
|
||||
}
|
||||
if p.kind == ParamKind::Select && p.options.is_empty() {
|
||||
return Err(format!("manifest: select parameter `{}` has no options", p.name));
|
||||
}
|
||||
if p.min.is_some_and(|v| !v.is_finite())
|
||||
|| p.max.is_some_and(|v| !v.is_finite())
|
||||
|| matches!((p.min, p.max), (Some(a), Some(b)) if a > b)
|
||||
{
|
||||
return Err(format!("manifest: parameter `{}` has bad bounds", p.name));
|
||||
}
|
||||
}
|
||||
if let Some(dup) = first_duplicate(c.params.iter().map(|p| p.name.as_str())) {
|
||||
return Err(format!("manifest: command `{}` declares `{dup}` twice", c.id));
|
||||
}
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The first name that occurs twice.
|
||||
fn first_duplicate<'a>(names: impl Iterator<Item = &'a str>) -> Option<&'a str> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
names.into_iter().find(|n| !seen.insert(*n))
|
||||
}
|
||||
|
||||
/// An id: 1..=`max` ASCII letters, digits, `.`, `-` or `_`.
|
||||
fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> {
|
||||
if id.is_empty() || id.len() > max || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) {
|
||||
return Err(format!("manifest: {what} `{}` must be 1–{max} letters, digits, `.`, `-` or `_`", truncate(id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trimmed text of at most `max` characters without control characters.
|
||||
fn clean(what: &str, s: &str, max: usize) -> Result<String, String> {
|
||||
let s = s.trim();
|
||||
if s.chars().count() > max {
|
||||
return Err(format!("manifest: {what} is longer than {max} characters"));
|
||||
}
|
||||
if s.chars().any(char::is_control) {
|
||||
return Err(format!("manifest: {what} contains control characters"));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
/// `main` names a `.wasm` file next to (or below) the manifest.
|
||||
fn check_main(main: &str) -> Result<(), String> {
|
||||
let bad = main.is_empty()
|
||||
|| main.len() > 256
|
||||
|| !main.to_ascii_lowercase().ends_with(".wasm")
|
||||
|| main.starts_with(['/', '\\'])
|
||||
|| main.contains(':')
|
||||
|| main.chars().any(char::is_control)
|
||||
|| main.split(['/', '\\']).any(|part| part == ".." || part.is_empty());
|
||||
if bad {
|
||||
return Err(format!("manifest: `main` must be a relative path to a .wasm file inside the plugin's folder (got `{}`)", truncate(main)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// At most 40 characters of `s`, for error messages.
|
||||
fn truncate(s: &str) -> String {
|
||||
let mut out: String = s.chars().take(40).collect();
|
||||
if s.chars().count() > 40 {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
//! The sandbox: a plugin module runs in a metered WebAssembly interpreter (wasmi; no JIT) with a
|
||||
//! memory cap, and sees the outside world only through the host functions below. There is no WASI:
|
||||
//! no file system, network, clock or randomness.
|
||||
//!
|
||||
//! # ABI (version 1)
|
||||
//!
|
||||
//! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are
|
||||
//! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory.
|
||||
//!
|
||||
//! | Import | Signature | Does |
|
||||
//! |---|---|---|
|
||||
//! | `call` | `(cmd_ptr, cmd_len, params_ptr, params_len) -> i32` | Runs a command with JSON params. 0: ok, the reply holds the result JSON; 1: refused or failed, the reply holds the message. |
|
||||
//! | `selection` | `() -> i32` | The reply becomes the selected layer ids (a JSON array); returns its length. |
|
||||
//! | `reply_len` | `() -> i32` | Length of the reply. |
|
||||
//! | `reply_read` | `(ptr, len) -> i32` | Copies up to `len` bytes of the reply to `ptr`; returns the count. |
|
||||
//! | `log` | `(ptr, len)` | A line for the run's log. |
|
||||
//! | `notify` | `(ptr, len)` | A short message shown to the user. |
|
||||
//! | `set_output` | `(ptr, len)` | The run's result (JSON or text), or its error message when it fails. |
|
||||
//!
|
||||
//! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes):
|
||||
//! `{"command", "params", "selection"}`. It returns 0 on success; anything else fails the run.
|
||||
|
||||
use wasmi::{Caller, Config, Engine, Extern, FuncType, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TrapCode, ValType};
|
||||
|
||||
/// The module host functions are imported from.
|
||||
pub const IMPORT_MODULE: &str = "figcraft";
|
||||
/// The exported entry point.
|
||||
pub const ENTRY: &str = "figcraft_run";
|
||||
|
||||
/// The host functions: (name, params, results), all i32.
|
||||
const IMPORTS: &[(&str, usize, usize)] =
|
||||
&[("call", 4, 1), ("selection", 0, 1), ("reply_len", 0, 1), ("reply_read", 2, 1), ("log", 2, 0), ("notify", 2, 0), ("set_output", 2, 0)];
|
||||
|
||||
/// What a run may use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more.
|
||||
pub fuel: u64,
|
||||
pub call_fuel: u64,
|
||||
/// Linear memory cap in bytes.
|
||||
pub memory_bytes: usize,
|
||||
/// Largest module accepted.
|
||||
pub module_bytes: usize,
|
||||
/// Host calls per run.
|
||||
pub max_calls: u32,
|
||||
/// Largest string passed either way (command params, results, logs).
|
||||
pub max_message: usize,
|
||||
/// Log lines kept per run.
|
||||
pub max_logs: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fuel: 1_000_000_000,
|
||||
call_fuel: 10_000,
|
||||
memory_bytes: 64 * 1024 * 1024,
|
||||
module_bytes: 16 * 1024 * 1024,
|
||||
max_calls: 100_000,
|
||||
max_message: 16 * 1024 * 1024,
|
||||
max_logs: 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a run failed.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum PluginError {
|
||||
#[error("not a valid plugin module: {0}")]
|
||||
InvalidModule(String),
|
||||
#[error("the plugin ran out of its instruction budget (an endless loop?)")]
|
||||
OutOfFuel,
|
||||
#[error("the plugin used more than its memory limit")]
|
||||
OutOfMemory,
|
||||
#[error("the plugin crashed: {0}")]
|
||||
Trap(String),
|
||||
#[error("the plugin went over a limit: {0}")]
|
||||
Limit(String),
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// What the sandbox asks of its host (the engine): run commands, read the selection, log.
|
||||
pub trait Host {
|
||||
/// Run `command` with `params` (JSON text): the result JSON, or why it was refused or failed.
|
||||
fn call(&mut self, command: &str, params: &str) -> Result<String, String>;
|
||||
/// The selected layer ids as a JSON array.
|
||||
fn selection(&mut self) -> String;
|
||||
/// A message for the user.
|
||||
fn notify(&mut self, message: &str);
|
||||
/// A line of the run's log, as the plugin writes it (it's also in [`RunOutput::logs`]).
|
||||
fn log(&mut self, _line: &str) {}
|
||||
}
|
||||
|
||||
/// A finished run.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct RunOutput {
|
||||
/// What the plugin passed to `set_output` (empty when nothing).
|
||||
pub output: String,
|
||||
pub logs: Vec<String>,
|
||||
/// Host calls made.
|
||||
pub calls: u32,
|
||||
/// Fuel used.
|
||||
pub fuel: u64,
|
||||
}
|
||||
|
||||
struct State<'h> {
|
||||
host: &'h mut dyn Host,
|
||||
limits: Limits,
|
||||
store_limits: StoreLimits,
|
||||
reply: Vec<u8>,
|
||||
out: RunOutput,
|
||||
/// A limit the host tripped (reported instead of the trap it causes).
|
||||
abort: Option<PluginError>,
|
||||
}
|
||||
|
||||
fn engine() -> Engine {
|
||||
let mut c = Config::default();
|
||||
c.consume_fuel(true);
|
||||
Engine::new(&c)
|
||||
}
|
||||
|
||||
fn func_type(params: usize, results: usize) -> FuncType {
|
||||
FuncType::new(vec![ValType::I32; params], vec![ValType::I32; results])
|
||||
}
|
||||
|
||||
/// Compile and check a module: its size, that it imports only FigCraft's host functions (no WASI)
|
||||
/// and exports `memory` and `figcraft_run`.
|
||||
pub fn check_module(bytes: &[u8], limits: &Limits) -> Result<(), PluginError> {
|
||||
compile(&engine(), bytes, limits).map(|_| ())
|
||||
}
|
||||
|
||||
fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, PluginError> {
|
||||
if bytes.len() > limits.module_bytes {
|
||||
return Err(PluginError::InvalidModule(format!("larger than {} MB", limits.module_bytes / (1024 * 1024))));
|
||||
}
|
||||
if !bytes.starts_with(b"\0asm") {
|
||||
return Err(PluginError::InvalidModule("not a WebAssembly binary".into()));
|
||||
}
|
||||
let module = Module::new(engine, bytes).map_err(|e| PluginError::InvalidModule(e.to_string()))?;
|
||||
for imp in module.imports() {
|
||||
let known = IMPORTS.iter().find(|(n, ..)| imp.module() == IMPORT_MODULE && *n == imp.name());
|
||||
match (known, imp.ty().func()) {
|
||||
(Some((_, p, r)), Some(ty)) if *ty == func_type(*p, *r) => {}
|
||||
(Some(_), _) => {
|
||||
return Err(PluginError::InvalidModule(format!("`{}.{}` is imported with the wrong signature", imp.module(), imp.name())));
|
||||
}
|
||||
(None, _) => {
|
||||
return Err(PluginError::InvalidModule(format!(
|
||||
"it imports `{}.{}`, which FigCraft doesn't provide (plugins get no file system, network or clock)",
|
||||
imp.module(),
|
||||
imp.name()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let export = |name: &str| module.exports().find(|e| e.name() == name).map(|e| e.ty().clone());
|
||||
if !export("memory").is_some_and(|t| t.memory().is_some()) {
|
||||
return Err(PluginError::InvalidModule("it doesn't export its `memory`".into()));
|
||||
}
|
||||
if !export(ENTRY).is_some_and(|t| t.func() == Some(&func_type(1, 1))) {
|
||||
return Err(PluginError::InvalidModule(format!("it doesn't export `{ENTRY}(input_len: i32) -> i32`")));
|
||||
}
|
||||
Ok(module)
|
||||
}
|
||||
|
||||
/// Run a plugin module: `input` is the JSON handed to `figcraft_run`.
|
||||
pub fn run(bytes: &[u8], input: &str, limits: &Limits, host: &mut dyn Host) -> Result<RunOutput, PluginError> {
|
||||
let engine = engine();
|
||||
let module = compile(&engine, bytes, limits)?;
|
||||
if input.len() > limits.max_message {
|
||||
return Err(PluginError::Limit("the input is too large".into()));
|
||||
}
|
||||
let store_limits = StoreLimitsBuilder::new().memory_size(limits.memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
|
||||
let state = State { host, limits: *limits, store_limits, reply: input.as_bytes().to_vec(), out: RunOutput::default(), abort: None };
|
||||
let mut store = Store::new(&engine, state);
|
||||
store.limiter(|s| &mut s.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
|
||||
let linker = linker(&engine)?;
|
||||
let instance = linker.instantiate_and_start(&mut store, &module).map_err(|e| classify(&mut store, &e, true))?;
|
||||
let entry = instance.get_typed_func::<i32, i32>(&store, ENTRY).map_err(|e| PluginError::InvalidModule(e.to_string()))?;
|
||||
let input_len = i32::try_from(input.len()).map_err(|_| PluginError::Limit("the input is too large".into()))?;
|
||||
let status = entry.call(&mut store, input_len).map_err(|e| classify(&mut store, &e, false))?;
|
||||
let fuel = limits.fuel.saturating_sub(store.get_fuel().unwrap_or(0));
|
||||
let mut out = std::mem::take(&mut store.data_mut().out);
|
||||
out.fuel = fuel;
|
||||
if status != 0 {
|
||||
let msg = if out.output.trim().is_empty() { format!("the plugin failed (status {status})") } else { out.output };
|
||||
return Err(PluginError::Failed(msg));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The error a failed instantiation or call stands for: a limit the host tripped first, else the
|
||||
/// trap.
|
||||
fn classify(store: &mut Store<State<'_>>, e: &wasmi::Error, instantiating: bool) -> PluginError {
|
||||
if let Some(abort) = store.data_mut().abort.take() {
|
||||
return abort;
|
||||
}
|
||||
match e.as_trap_code() {
|
||||
Some(TrapCode::OutOfFuel) => PluginError::OutOfFuel,
|
||||
Some(TrapCode::GrowthOperationLimited) => PluginError::OutOfMemory,
|
||||
Some(code) => PluginError::Trap(code.to_string()),
|
||||
// A memory larger than the cap fails instantiation.
|
||||
None if instantiating && e.to_string().to_ascii_lowercase().contains("limit") => PluginError::OutOfMemory,
|
||||
None if instantiating => PluginError::InvalidModule(e.to_string()),
|
||||
None => PluginError::Trap(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx<'a, 'h> = Caller<'a, State<'h>>;
|
||||
|
||||
/// Stop the run because of `err` (the trap it causes reports `err`).
|
||||
fn abort(caller: &mut Ctx<'_, '_>, err: PluginError) -> wasmi::Error {
|
||||
let msg = err.to_string();
|
||||
caller.data_mut().abort = Some(err);
|
||||
wasmi::Error::new(msg)
|
||||
}
|
||||
|
||||
/// Charge a host call: fuel and the call count.
|
||||
fn charge(caller: &mut Ctx<'_, '_>) -> Result<(), wasmi::Error> {
|
||||
let cost = caller.data().limits.call_fuel;
|
||||
let left = caller.get_fuel()?;
|
||||
if left < cost {
|
||||
return Err(abort(caller, PluginError::OutOfFuel));
|
||||
}
|
||||
caller.set_fuel(left - cost)?;
|
||||
let st = caller.data_mut();
|
||||
st.out.calls = st.out.calls.saturating_add(1);
|
||||
if st.out.calls > st.limits.max_calls {
|
||||
let n = st.limits.max_calls;
|
||||
return Err(abort(caller, PluginError::Limit(format!("more than {n} host calls"))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bytes at `(ptr, len)` in the plugin's memory.
|
||||
fn read_bytes(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
|
||||
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
|
||||
if len > caller.data().limits.max_message {
|
||||
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
|
||||
}
|
||||
let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else {
|
||||
return Err(abort(caller, PluginError::InvalidModule("no memory".into())));
|
||||
};
|
||||
match ptr.checked_add(len).and_then(|end| mem.data(&*caller).get(ptr..end)) {
|
||||
Some(b) => Ok(b.to_vec()),
|
||||
None => Err(wasmi::Error::from(TrapCode::MemoryOutOfBounds)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The UTF-8 text at `(ptr, len)`.
|
||||
fn read_str(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<String, wasmi::Error> {
|
||||
let bytes = read_bytes(caller, ptr, len)?;
|
||||
String::from_utf8(bytes).map_err(|_| abort(caller, PluginError::Trap("a string isn't valid UTF-8".into())))
|
||||
}
|
||||
|
||||
/// Make `bytes` the reply; returns its length.
|
||||
fn set_reply(caller: &mut Ctx<'_, '_>, bytes: Vec<u8>) -> Result<i32, wasmi::Error> {
|
||||
let max = caller.data().limits.max_message;
|
||||
let bytes = if bytes.len() > max { format!("the result is larger than {max} bytes").into_bytes() } else { bytes };
|
||||
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||
caller.data_mut().reply = bytes;
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
fn linker<'h>(engine: &Engine) -> Result<Linker<State<'h>>, PluginError> {
|
||||
let mut l = <Linker<State<'h>>>::new(engine);
|
||||
let err = |e: wasmi::errors::LinkerError| PluginError::InvalidModule(e.to_string());
|
||||
l.func_wrap(IMPORT_MODULE, "call", |mut c: Ctx<'_, 'h>, cp: i32, cl: i32, pp: i32, pl: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let cmd = read_str(&mut c, cp, cl)?;
|
||||
let params = read_str(&mut c, pp, pl)?;
|
||||
let params = if params.trim().is_empty() { "{}".to_string() } else { params };
|
||||
let (status, reply) = match c.data_mut().host.call(&cmd, ¶ms) {
|
||||
Ok(v) => (0, v),
|
||||
Err(m) => (1, m),
|
||||
};
|
||||
set_reply(&mut c, reply.into_bytes())?;
|
||||
Ok(status)
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "selection", |mut c: Ctx<'_, 'h>| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let sel = c.data_mut().host.selection();
|
||||
set_reply(&mut c, sel.into_bytes())
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_, 'h>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
|
||||
let (ptr, want) = (ptr as u32 as usize, len as u32 as usize);
|
||||
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
|
||||
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
|
||||
};
|
||||
let (data, st) = mem.data_and_store_mut(&mut c);
|
||||
let n = want.min(st.reply.len());
|
||||
let src = st.reply.get(..n).ok_or(TrapCode::MemoryOutOfBounds)?;
|
||||
let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?;
|
||||
dst.copy_from_slice(src);
|
||||
Ok(i32::try_from(n).unwrap_or(i32::MAX))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "log", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let line = read_str(&mut c, ptr, len)?;
|
||||
let st = c.data_mut();
|
||||
st.host.log(&line);
|
||||
if st.out.logs.len() < st.limits.max_logs {
|
||||
st.out.logs.push(line);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "notify", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let msg = read_str(&mut c, ptr, len)?;
|
||||
let msg: String = msg.chars().filter(|ch| !ch.is_control()).take(200).collect();
|
||||
c.data_mut().host.notify(&msg);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
let out = read_str(&mut c, ptr, len)?;
|
||||
c.data_mut().out.output = out;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(err)?;
|
||||
Ok(l)
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
//! The sandbox (modules assembled from WAT text at test time) and the manifest.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Access, Host, Limits, Manifest, PluginError, check_module, run};
|
||||
|
||||
/// Records what a plugin asked for and answers `call` with a canned reply.
|
||||
#[derive(Default)]
|
||||
struct Mock {
|
||||
calls: Vec<(String, String)>,
|
||||
notes: Vec<String>,
|
||||
refuse: bool,
|
||||
}
|
||||
|
||||
impl Host for Mock {
|
||||
fn call(&mut self, command: &str, params: &str) -> Result<String, String> {
|
||||
self.calls.push((command.into(), params.into()));
|
||||
if self.refuse { Err(format!("`{command}` refused")) } else { Ok(format!("{{\"ran\":\"{command}\"}}")) }
|
||||
}
|
||||
fn selection(&mut self) -> String {
|
||||
"[2,3]".into()
|
||||
}
|
||||
fn notify(&mut self, message: &str) {
|
||||
self.notes.push(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
const IMPORTS: &str = r#"
|
||||
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "selection" (func $selection (result i32)))
|
||||
(import "figcraft" "reply_len" (func $reply_len (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "log" (func $log (param i32 i32)))
|
||||
(import "figcraft" "notify" (func $notify (param i32 i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
"#;
|
||||
|
||||
/// A module with the host imports, one page of memory, `data` segments and `figcraft_run`'s body.
|
||||
fn module(data: &str, body: &str) -> Vec<u8> {
|
||||
module_with(data, body, "(memory (export \"memory\") 1)", "")
|
||||
}
|
||||
|
||||
fn module_with(data: &str, body: &str, memory: &str, extra: &str) -> Vec<u8> {
|
||||
let wat = format!("(module {IMPORTS} {memory} {data} {extra} (func (export \"figcraft_run\") (param $n i32) (result i32) {body}))");
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
fn small() -> Limits {
|
||||
Limits { fuel: 2_000_000, ..Limits::default() }
|
||||
}
|
||||
|
||||
fn go(bytes: &[u8], input: &str) -> (Result<crate::RunOutput, PluginError>, Mock) {
|
||||
let mut host = Mock::default();
|
||||
let r = run(bytes, input, &small(), &mut host);
|
||||
(r, host)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echoes_input_calls_commands_and_reads_replies() {
|
||||
let m = module(
|
||||
r#"(data (i32.const 0) "shape.rectangle") (data (i32.const 32) "{\"x\":1}") (data (i32.const 64) "made it")"#,
|
||||
r#"
|
||||
;; The input is waiting in the reply buffer.
|
||||
(drop (call $reply_read (i32.const 1024) (local.get $n)))
|
||||
(call $log (i32.const 1024) (local.get $n))
|
||||
(if (i32.ne (call $call (i32.const 0) (i32.const 15) (i32.const 32) (i32.const 7)) (i32.const 0)) (then (return (i32.const 1))))
|
||||
;; Hand the command's reply back as the output.
|
||||
(call $set_output (i32.const 2048) (call $reply_read (i32.const 2048) (call $reply_len)))
|
||||
(call $notify (i32.const 64) (i32.const 7))
|
||||
(i32.const 0)"#,
|
||||
);
|
||||
check_module(&m, &Limits::default()).unwrap();
|
||||
let (r, host) = go(&m, r#"{"command":"run"}"#);
|
||||
let out = r.unwrap();
|
||||
assert_eq!(out.output, r#"{"ran":"shape.rectangle"}"#);
|
||||
assert_eq!(out.logs, vec![r#"{"command":"run"}"#.to_string()]);
|
||||
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
|
||||
assert_eq!(host.notes, vec!["made it".to_string()]);
|
||||
assert_eq!(out.calls, 3);
|
||||
assert!(out.fuel > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_and_refusals_reach_the_plugin() {
|
||||
let m = module(
|
||||
r#"(data (i32.const 0) "node.rename")"#,
|
||||
r#"
|
||||
(drop (call $selection))
|
||||
(drop (call $reply_read (i32.const 512) (i32.const 64)))
|
||||
;; Empty params count as {}; status 1 when refused, the message in the reply.
|
||||
(if (i32.eq (call $call (i32.const 0) (i32.const 11) (i32.const 0) (i32.const 0)) (i32.const 1))
|
||||
(then (call $set_output (i32.const 1024) (call $reply_read (i32.const 1024) (call $reply_len)))))
|
||||
(i32.const 0)"#,
|
||||
);
|
||||
let mut host = Mock { refuse: true, ..Mock::default() };
|
||||
let out = run(&m, "{}", &small(), &mut host).unwrap();
|
||||
assert_eq!(out.output, "`node.rename` refused");
|
||||
assert_eq!(host.calls[0].1, "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonzero_status_fails_the_run_with_its_output() {
|
||||
let m = module(r#"(data (i32.const 0) "nothing selected")"#, "(call $set_output (i32.const 0) (i32.const 16)) (i32.const 2)");
|
||||
assert_eq!(go(&m, "{}").0, Err(PluginError::Failed("nothing selected".into())));
|
||||
let m = module("", "(i32.const 7)");
|
||||
assert!(matches!(go(&m, "{}").0, Err(PluginError::Failed(m)) if m.contains("status 7")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_endless_loop_runs_out_of_fuel() {
|
||||
let m = module("", "(loop $l (br $l)) (i32.const 0)");
|
||||
assert_eq!(go(&m, "{}").0, Err(PluginError::OutOfFuel));
|
||||
// Host calls cost fuel too, and their number is capped.
|
||||
let m = module("", "(loop $l (call $log (i32.const 0) (i32.const 0)) (br $l)) (i32.const 0)");
|
||||
assert_eq!(go(&m, "{}").0, Err(PluginError::OutOfFuel));
|
||||
let mut host = Mock::default();
|
||||
let limits = Limits { max_calls: 10, ..small() };
|
||||
assert!(matches!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_is_capped() {
|
||||
// Declared larger than the cap: refused at instantiation.
|
||||
let big = module_with("", "(i32.const 0)", "(memory (export \"memory\") 2000)", "");
|
||||
assert_eq!(go(&big, "{}").0, Err(PluginError::OutOfMemory));
|
||||
// Growing past the cap fails (memory.grow returns -1) and the plugin can tell.
|
||||
let grow = module(
|
||||
r#"(data (i32.const 0) "refused")"#,
|
||||
r#"(if (i32.eq (memory.grow (i32.const 1500)) (i32.const -1)) (then (call $set_output (i32.const 0) (i32.const 7)))) (i32.const 0)"#,
|
||||
);
|
||||
assert_eq!(go(&grow, "{}").0.unwrap().output, "refused");
|
||||
// Within the cap it grows.
|
||||
let ok = module("", "(drop (memory.grow (i32.const 10))) (i32.const 0)");
|
||||
assert!(go(&ok, "{}").0.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traps_fail_cleanly() {
|
||||
let cases = [
|
||||
module("", "unreachable"),
|
||||
module("", "(i32.div_u (i32.const 1) (i32.const 0))"),
|
||||
// Out-of-bounds strings and replies.
|
||||
module("", "(call $log (i32.const 65530) (i32.const 100)) (i32.const 0)"),
|
||||
module("", "(call $log (i32.const -1) (i32.const 2)) (i32.const 0)"),
|
||||
module("", "(drop (call $reply_read (i32.const 65535) (i32.const 100))) (i32.const 0)"),
|
||||
// Not UTF-8.
|
||||
module(r#"(data (i32.const 0) "\ff\fe")"#, "(call $log (i32.const 0) (i32.const 2)) (i32.const 0)"),
|
||||
// Endless recursion exhausts the interpreter's stack, not the app's.
|
||||
module_with("", "(call $rec) (i32.const 0)", "(memory (export \"memory\") 1)", "(func $rec (call $rec))"),
|
||||
];
|
||||
for m in &cases {
|
||||
let r = go(m, "{}").0;
|
||||
assert!(matches!(r, Err(PluginError::Trap(_)) | Err(PluginError::Limit(_))), "{r:?}");
|
||||
}
|
||||
// A huge length is refused before anything is read.
|
||||
let m = module("", "(call $log (i32.const 0) (i32.const 2147483647)) (i32.const 0)");
|
||||
assert!(matches!(go(&m, "{}").0, Err(PluginError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_without_the_abi_are_refused() {
|
||||
let invalid = |wat: &str| {
|
||||
let bytes = wat::parse_str(wat).unwrap();
|
||||
let e = check_module(&bytes, &Limits::default()).unwrap_err();
|
||||
assert!(matches!(e, PluginError::InvalidModule(_)), "{e:?}");
|
||||
assert_eq!(go(&bytes, "{}").0.unwrap_err(), e);
|
||||
e.to_string()
|
||||
};
|
||||
let wasi = invalid(
|
||||
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)))"#,
|
||||
);
|
||||
assert!(wasi.contains("no file system"), "{wasi}");
|
||||
invalid(
|
||||
r#"(module (import "figcraft" "call" (func (param i32) (result i32))) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#,
|
||||
);
|
||||
invalid(
|
||||
r#"(module (import "figcraft" "launch_rockets" (func)) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#,
|
||||
);
|
||||
invalid(r#"(module (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#);
|
||||
invalid(r#"(module (memory (export "memory") 1))"#);
|
||||
invalid(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (result i32) (i32.const 0)))"#);
|
||||
for junk in [&b""[..], b"\0asm", b"\0asm\x01\0\0\0\x01", b"not wasm at all", &[0xff; 64]] {
|
||||
assert!(matches!(check_module(junk, &Limits::default()), Err(PluginError::InvalidModule(_))));
|
||||
}
|
||||
let tiny = Limits { module_bytes: 8, ..Limits::default() };
|
||||
assert!(matches!(check_module(&module("", "(i32.const 0)"), &tiny), Err(PluginError::InvalidModule(m)) if m.contains("larger")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mangled_modules_never_panic() {
|
||||
// A cheap fuzz: flip bytes of a valid module all over and run what still compiles.
|
||||
let base = module(
|
||||
r#"(data (i32.const 0) "node.get")"#,
|
||||
"(drop (call $call (i32.const 0) (i32.const 8) (i32.const 0) (i32.const 0))) (loop $l (br_if $l (i32.const 0))) (i32.const 0)",
|
||||
);
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed
|
||||
};
|
||||
let limits = Limits { fuel: 100_000, ..Limits::default() };
|
||||
for _ in 0..600 {
|
||||
let mut m = base.clone();
|
||||
for _ in 0..1 + next() % 4 {
|
||||
let i = next() as usize % m.len();
|
||||
m[i] = next() as u8;
|
||||
}
|
||||
if next() % 5 == 0 {
|
||||
m.truncate(next() as usize % m.len());
|
||||
}
|
||||
let _ = check_module(&m, &limits);
|
||||
let mut host = Mock::default();
|
||||
let _ = run(&m, "{}", &limits, &mut host);
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest(extra: Value) -> Result<Manifest, String> {
|
||||
let mut v = json!({"id": "org.example.tidy", "name": "Tidy", "version": "1.0.0", "main": "tidy.wasm"});
|
||||
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
Manifest::from_value(&v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifests_validate_and_fill_defaults() {
|
||||
let m = manifest(json!({})).unwrap();
|
||||
assert_eq!(m.permissions.access, Access::Read, "read-only unless asked");
|
||||
assert_eq!(m.commands.len(), 1, "one `run` command when none are listed");
|
||||
assert_eq!(m.command(None).unwrap().id, "run");
|
||||
let m = manifest(json!({
|
||||
"permissions": {"access": "write", "commands": ["node.*", "shape.rectangle"]},
|
||||
"commands": [{"id": "grid", "label": "Swatch grid", "params": [
|
||||
{"name": "columns", "type": "number", "default": 4, "min": 1, "max": 12},
|
||||
{"name": "palette", "type": "text", "default": "#f00,#0f0"},
|
||||
{"name": "rounded", "type": "boolean"},
|
||||
{"name": "size", "type": "select", "options": ["S", "M", "L"], "default": "M"}
|
||||
]}, {"id": "other", "label": ""}]
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(m.permissions.allows("node.rename") && m.permissions.allows("shape.rectangle"));
|
||||
assert!(!m.permissions.allows("shape.ellipse") && !m.permissions.allows("file.save"));
|
||||
assert_eq!(m.command(Some("other")).unwrap().label, "other", "an empty label falls back to the id");
|
||||
let grid = m.command(Some("grid")).unwrap();
|
||||
let p = grid.resolve_params(&json!({"columns": 40, "extra": [1]})).unwrap();
|
||||
assert_eq!(p, json!({"columns": 12.0, "palette": "#f00,#0f0", "rounded": false, "size": "M", "extra": [1]}));
|
||||
assert_eq!(grid.resolve_params(&json!({"columns": "3"})).unwrap()["columns"], json!(3.0));
|
||||
assert!(grid.resolve_params(&json!({"size": "XL"})).is_err());
|
||||
assert!(grid.resolve_params(&json!({"rounded": "yes"})).is_err());
|
||||
assert!(grid.resolve_params(&json!({"columns": "NaN"})).is_err());
|
||||
assert!(grid.resolve_params(&json!([1])).is_err());
|
||||
assert!(m.command(Some("missing")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_manifests_are_refused() {
|
||||
let bad = [
|
||||
json!({"id": ""}),
|
||||
json!({"id": "has space"}),
|
||||
json!({"id": "x".repeat(200)}),
|
||||
json!({"name": ""}),
|
||||
json!({"name": "line\nbreak"}),
|
||||
json!({"main": "../escape.wasm"}),
|
||||
json!({"main": "/abs/plugin.wasm"}),
|
||||
json!({"main": "C:\\plugin.wasm"}),
|
||||
json!({"main": "plugin.js"}),
|
||||
json!({"main": "a//b.wasm"}),
|
||||
json!({"api": 2}),
|
||||
json!({"api": 0}),
|
||||
json!({"permissions": {"access": "root"}}),
|
||||
json!({"permissions": {"commands": ["*.x"]}}),
|
||||
json!({"permissions": {"commands": [""]}}),
|
||||
json!({"commands": [{"id": "a", "label": "A"}, {"id": "a", "label": "B"}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "s", "type": "select"}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "number", "min": 5, "max": 1}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n"}, {"name": "n"}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "color"}]}]}),
|
||||
json!({"commands": "run"}),
|
||||
];
|
||||
for extra in bad {
|
||||
assert!(manifest(extra.clone()).is_err(), "accepted {extra}");
|
||||
}
|
||||
let deep = "[".repeat(10_000);
|
||||
let huge = format!("{{\"id\":\"{}\"}}", "a".repeat(300_000));
|
||||
for junk in ["", "null", "[]", "{", "\"id\"", "{\"id\": 5}", &deep, &huge] {
|
||||
assert!(Manifest::parse(junk).is_err(), "accepted {}", junk.chars().take(40).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
|
@ -293,8 +293,27 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
|||
if let Some(h) = p.get("palette").and_then(Value::as_bool) {
|
||||
app.ui.palette = if h { Some(String::new()) } else { None };
|
||||
}
|
||||
if let Some(h) = p.get("mainMenu").and_then(Value::as_bool) {
|
||||
app.ui.main_menu = h;
|
||||
// true / false, or a top-level menu to open it at ("Plugins").
|
||||
match p.get("mainMenu") {
|
||||
Some(Value::Bool(h)) => app.ui.main_menu = *h,
|
||||
Some(Value::String(top)) => {
|
||||
app.ui.main_menu = true;
|
||||
crate::menus::open_main_menu(ctx, top);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
|
||||
app.ui.plugins_window = h;
|
||||
}
|
||||
// A plugin command's parameters form: {id, command?}; null closes it.
|
||||
match p.get("pluginForm") {
|
||||
Some(Value::Null) => app.ui.plugin_form = None,
|
||||
Some(v @ Value::Object(_)) => {
|
||||
let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
|
||||
let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
|
||||
crate::plugins_ui::start(app, id, command);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
|
||||
if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
export_dialog(app, ctx);
|
||||
}
|
||||
color_profile_dialog(app, ctx);
|
||||
crate::plugins_ui::show(app, ctx);
|
||||
}
|
||||
|
||||
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ pub mod layout_panel;
|
|||
pub mod list_drag;
|
||||
pub mod measure_overlay;
|
||||
pub mod menus;
|
||||
pub mod plugins_ui;
|
||||
pub mod present;
|
||||
pub mod proto_canvas;
|
||||
pub mod proto_editor;
|
||||
|
|
@ -270,6 +271,10 @@ pub struct UiState {
|
|||
pub libraries: bool,
|
||||
/// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab).
|
||||
pub nav: a11y::Nav,
|
||||
/// The Manage plugins window is open.
|
||||
pub plugins_window: bool,
|
||||
/// A plugin command's parameters form, when open.
|
||||
pub plugin_form: Option<plugins_ui::PluginForm>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
|
|
@ -335,6 +340,8 @@ impl Default for UiState {
|
|||
next_autosave: 0.0,
|
||||
libraries: false,
|
||||
nav: a11y::Nav::default(),
|
||||
plugins_window: false,
|
||||
plugin_form: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -521,6 +528,7 @@ impl FigApp {
|
|||
comments::start_draft(self, x, y);
|
||||
}
|
||||
"dialog.unsaved" => self.toast("This file has unsaved changes"),
|
||||
"dialog.plugins" => self.ui.plugins_window = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use crate::toolbar::pretty_shortcut;
|
|||
|
||||
/// Commands the UI layer implements itself (dialogs and platform actions).
|
||||
pub const UI_COMMANDS: &[&str] = &[
|
||||
"plugin.import",
|
||||
"file.open",
|
||||
"file.import",
|
||||
"file.placeImage",
|
||||
|
|
@ -74,6 +75,7 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, u32, u32)> {
|
|||
pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
match id {
|
||||
"file.open" => app.open_dialog(),
|
||||
"plugin.import" => crate::plugins_ui::import_dialog(app),
|
||||
"file.import" => app.import_dialog(false),
|
||||
"file.placeImage" => app.import_dialog(true),
|
||||
"file.save" => app.save(),
|
||||
|
|
@ -159,6 +161,10 @@ pub fn available(id: &str) -> bool {
|
|||
/// Draw one level of the main menu: `prefix` is the menu path ("File", "Edit/Copy as").
|
||||
fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The installed plugins head the Plugins menu.
|
||||
if prefix == "Plugins" && crate::plugins_ui::menu(app, ui, &t) {
|
||||
return true;
|
||||
}
|
||||
let mut clicked = None;
|
||||
let mut subs: Vec<String> = Vec::new();
|
||||
let mut last_sep = true;
|
||||
|
|
@ -182,22 +188,8 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
|||
subs.push(sub.clone());
|
||||
last_sep = false;
|
||||
let full = format!("{prefix}/{sub}");
|
||||
let resp = submenu_item(ui, &sub, &t);
|
||||
if resp.hovered() || resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(Id::new(("submenu_open", prefix)), full.clone()));
|
||||
}
|
||||
let open: Option<String> = ui.data(|d| d.get_temp(Id::new(("submenu_open", prefix))));
|
||||
if open.as_deref() == Some(full.as_str()) {
|
||||
let pos = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0);
|
||||
let mut child_clicked = false;
|
||||
egui::Area::new(Id::new(("submenu", full.as_str()))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
menu_frame(ui, &t, |ui| {
|
||||
child_clicked = menu_level(app, ui, &full);
|
||||
});
|
||||
});
|
||||
if child_clicked {
|
||||
return true;
|
||||
}
|
||||
if submenu(ui, prefix, &full, &sub, &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,15 +201,31 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
/// A submenu row `label` in the menu at `prefix`; while it's open (hovered last), `add` draws its
|
||||
/// items beside it. `key` is unique among the menu's submenus. Returns what `add` returned.
|
||||
pub(crate) fn submenu<R>(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
|
||||
let resp = submenu_item(ui, label, t);
|
||||
let slot = Id::new(("submenu_open", prefix));
|
||||
if resp.hovered() || resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(slot, key.to_string()));
|
||||
}
|
||||
let open: Option<String> = ui.data(|d| d.get_temp(slot));
|
||||
if open.as_deref() != Some(key) {
|
||||
return None;
|
||||
}
|
||||
let pos = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0);
|
||||
egui::Area::new(Id::new(("submenu", key))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| menu_frame(ui, t, add)).inner
|
||||
}
|
||||
|
||||
/// A thin line between groups of menu items.
|
||||
fn separator(ui: &mut Ui) {
|
||||
pub(crate) fn separator(ui: &mut Ui) {
|
||||
ui.add_space(4.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());
|
||||
ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, Color32::from_white_alpha(30)));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui)) {
|
||||
fn menu_frame<R>(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
egui::Frame::NONE
|
||||
.fill(t.menu)
|
||||
.corner_radius(CornerRadius::same(13))
|
||||
|
|
@ -226,11 +234,12 @@ fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui)) {
|
|||
.show(ui, |ui| {
|
||||
ui.set_width(240.0);
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
add(ui);
|
||||
});
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool {
|
||||
pub(crate) fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool {
|
||||
indented_item(ui, label, shortcut, enabled, checked, 0.0, t)
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +288,11 @@ fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response {
|
|||
resp
|
||||
}
|
||||
|
||||
/// Open the main menu at top-level menu `top` (control channel).
|
||||
pub fn open_main_menu(ctx: &egui::Context, top: &str) {
|
||||
ctx.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string()));
|
||||
}
|
||||
|
||||
/// The main menu's area.
|
||||
pub fn main_menu_id() -> Id {
|
||||
Id::new("main_menu")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
//! Plugins in the UI: the installed plugins in the Plugins menu, the parameters form a command
|
||||
//! with parameters asks for before it runs, and the Manage plugins window. Everything runs through
|
||||
//! the engine's `plugin.*` commands.
|
||||
|
||||
use egui::{Context, Id, RichText, Ui};
|
||||
use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::dialogs::window;
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets::{self, FieldEdit, Lead};
|
||||
|
||||
/// The parameters form of a plugin command, with the values entered so far.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PluginForm {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub values: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Width of the plugin windows' fields.
|
||||
const FIELD_W: f32 = 220.0;
|
||||
|
||||
/// Start plugin `id`'s `command` (empty: its first): ask for its parameters first when it has any.
|
||||
pub fn start(app: &mut FigApp, id: &str, command: &str) {
|
||||
let found = app.session.prefs.plugins.iter().find(|p| p.manifest.id == id).and_then(|p| p.manifest.command(Some(command)));
|
||||
match found {
|
||||
Some(c) if !c.params.is_empty() => {
|
||||
let values = c.params.iter().map(|p| (p.name.clone(), p.default_value())).collect();
|
||||
app.ui.plugin_form = Some(PluginForm { id: id.to_string(), command: c.id.clone(), values });
|
||||
}
|
||||
_ => run(app, id, command, Value::Object(Map::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a plugin command; a text output is shown as a toast unless the plugin showed a message.
|
||||
fn run(app: &mut FigApp, id: &str, command: &str, params: Value) {
|
||||
let toast = app.ui.toast.clone();
|
||||
let Some(out) = app.run("plugin.run", json!({"id": id, "command": command, "params": params})) else { return };
|
||||
if app.ui.toast == toast
|
||||
&& let Some(text) = out["output"].as_str().filter(|t| !t.trim().is_empty())
|
||||
{
|
||||
app.toast(text.chars().take(200).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugins ▸ Development ▸ Import plugin from manifest…: pick a `figcraft-plugin.json`.
|
||||
pub fn import_dialog(app: &mut FigApp) {
|
||||
let Some(p) = app.services.pick_open("Import plugin from manifest", &["json"]) else { return };
|
||||
if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) {
|
||||
app.toast(format!("Imported {}", v["name"].as_str().unwrap_or("the plugin")));
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed plugins at the top of the Plugins menu: a plugin with one command is an item, one
|
||||
/// with several a submenu. True when one was clicked.
|
||||
pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool {
|
||||
if app.session.prefs.plugins.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut chosen: Option<(String, String)> = None;
|
||||
for p in &app.session.prefs.plugins {
|
||||
let m = &p.manifest;
|
||||
match m.commands.as_slice() {
|
||||
[only] => {
|
||||
// An ellipsis: it asks for parameters first.
|
||||
let label = if only.params.is_empty() { m.name.clone() } else { format!("{}…", m.name) };
|
||||
if crate::menus::item(ui, &label, "", true, false, t) {
|
||||
chosen = Some((m.id.clone(), only.id.clone()));
|
||||
}
|
||||
}
|
||||
commands => {
|
||||
let picked = crate::menus::submenu(ui, "Plugins", &format!("Plugins/plugin:{}", m.id), &m.name, t, |ui| {
|
||||
commands.iter().find(|c| crate::menus::item(ui, &c.label, "", true, false, t)).map(|c| c.id.clone())
|
||||
});
|
||||
if let Some(c) = picked {
|
||||
chosen = Some((m.id.clone(), c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::menus::separator(ui);
|
||||
let Some((id, command)) = chosen else { return false };
|
||||
start(app, &id, &command);
|
||||
true
|
||||
}
|
||||
|
||||
/// The plugin windows (Manage plugins, a command's parameters).
|
||||
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
if app.ui.plugins_window {
|
||||
manage_window(app, ctx);
|
||||
}
|
||||
if app.ui.plugin_form.is_some() {
|
||||
form_window(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
window("Plugins", ctx).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
let plugins = &app.session.prefs.plugins;
|
||||
if plugins.is_empty() {
|
||||
widgets::label(ui, "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(280.0);
|
||||
ui.label(RichText::new(&p.manifest.name).font(semibold(12.0)).color(t.text));
|
||||
widgets::label(ui, &summary(p), t.text_secondary);
|
||||
});
|
||||
if widgets::button(ui, "Remove", false, true).clicked() {
|
||||
remove = Some(p.manifest.id.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
if widgets::button(ui, "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}));
|
||||
}
|
||||
if import {
|
||||
import_dialog(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// "1.0 · can edit · development: <path>" under a plugin's name.
|
||||
fn summary(p: &InstalledPlugin) -> String {
|
||||
let m = &p.manifest;
|
||||
let access = match m.permissions.access {
|
||||
Access::Read => "reads the document",
|
||||
Access::Write => "can edit the document",
|
||||
};
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !m.version.is_empty() {
|
||||
parts.push(m.version.clone());
|
||||
}
|
||||
parts.push(access.to_string());
|
||||
parts.push(match &p.path {
|
||||
Some(path) => format!("development: {path}"),
|
||||
None => "this session only".into(),
|
||||
});
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn form_window(app: &mut FigApp, ctx: &Context) {
|
||||
let Some(mut form) = app.ui.plugin_form.clone() else { return };
|
||||
let Some((name, command)) = app
|
||||
.session
|
||||
.prefs
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|p| p.manifest.id == form.id)
|
||||
.and_then(|p| Some((p.manifest.name.clone(), p.manifest.command(Some(&form.command))?.clone())))
|
||||
else {
|
||||
app.ui.plugin_form = None;
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
let mut submit = false;
|
||||
let mut cancel = false;
|
||||
let label = command.label.trim_end_matches(['…', '.']).trim();
|
||||
let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") };
|
||||
window(&title, ctx).id(Id::new("plugin_form")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(320.0);
|
||||
egui::Grid::new("plugin_form_grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
for p in &command.params {
|
||||
ui.label(RichText::new(p.title()).font(regular(11.0)).color(t.text_secondary));
|
||||
let current = form.values.get(&p.name).cloned().unwrap_or_else(|| p.default_value());
|
||||
if let Some(v) = field(ui, &form, p, ¤t) {
|
||||
form.values.insert(p.name.clone(), v);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal(|ui| {
|
||||
submit = widgets::button(ui, "Run", true, true).clicked();
|
||||
cancel = widgets::button(ui, "Cancel", false, true).clicked();
|
||||
});
|
||||
});
|
||||
app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone());
|
||||
if submit {
|
||||
run(app, &form.id, &form.command, Value::Object(form.values));
|
||||
}
|
||||
}
|
||||
|
||||
/// One parameter's field; the new value when it changed.
|
||||
fn field(ui: &mut Ui, form: &PluginForm, p: &Param, current: &Value) -> Option<Value> {
|
||||
let id = Id::new(("plugin_param", &form.id, &form.command, &p.name));
|
||||
match p.kind {
|
||||
ParamKind::Text => widgets::text_field(ui, id, current.as_str().unwrap_or_default(), FIELD_W, "").map(Value::String),
|
||||
ParamKind::Number => match widgets::number_field(ui, id, Lead::None, current.as_f64(), "", FIELD_W, 1.0)? {
|
||||
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => p.coerce(&json!(v)).ok(),
|
||||
},
|
||||
ParamKind::Boolean => {
|
||||
let on = current.as_bool().unwrap_or(false);
|
||||
widgets::checkbox(ui, on, "").then_some(Value::Bool(!on))
|
||||
}
|
||||
ParamKind::Select => {
|
||||
let resp = widgets::dropdown(ui, current.as_str().unwrap_or_default(), FIELD_W, None);
|
||||
let mut picked = None;
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for o in &p.options {
|
||||
if crate::right::menu_item(ui, o, "") {
|
||||
picked = Some(Value::String(o.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
picked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -428,7 +428,9 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
|
|||
}
|
||||
None => {
|
||||
let (s, col) = if value.is_empty() { (placeholder, t.text_tertiary) } else { (value, t.text) };
|
||||
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col);
|
||||
// Long text stays inside the field.
|
||||
let painter = ui.painter().with_clip_rect(rect.shrink2(vec2(8.0, 0.0)));
|
||||
painter.text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
|
|||
|
|
@ -142,10 +142,13 @@ 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?}`
|
||||
### `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?}`
|
||||
|
||||
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
|
||||
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `focusRegion`
|
||||
`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
|
||||
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:
|
||||
every UI icon at 12, 16, 24 and 32 px over the window, for reviewing the icon set), `colorPicker`
|
||||
|
|
|
|||
13
docs/mcp.md
13
docs/mcp.md
|
|
@ -477,6 +477,19 @@ closing or saving a version ends the preview first, and `version.list` marks the
|
|||
has unsaved changes since its last version; the app runs it every 30 minutes for each open document,
|
||||
and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app.
|
||||
|
||||
### Plugins
|
||||
|
||||
Sandboxed WebAssembly plugins act through the same commands (see [`plugins.md`](plugins.md) for the
|
||||
manifest, the ABI and the Rust SDK). Driven with `run_command`: `plugin.import {path}` (a
|
||||
`figcraft-plugin.json` or its folder) or `plugin.import {manifest, wasm}` (the manifest as an
|
||||
object or JSON text and the module as base64, for this session only), `plugin.list`,
|
||||
`plugin.remove {id}`, and `plugin.run {id, command?, params?, fuel?}`, which returns
|
||||
`{plugin, command, output, logs, calls, fuel}`. A run is one undo step and is rolled back when it
|
||||
fails; a plugin only reads the document unless its manifest asks for `write`, may be limited to
|
||||
some commands, and can never touch files, other documents, the undo history, dialogs or other
|
||||
plugins. A headless MCP session starts with no plugins: import them first. `plugins.runLast` runs
|
||||
the last plugin command again.
|
||||
|
||||
### Dev Mode
|
||||
|
||||
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
# Parity report
|
||||
|
||||
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (442 commands in the engine).
|
||||
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (455 commands in the engine).
|
||||
|
||||
**197 / 202 menu items (97.5%)**
|
||||
**200 / 203 menu items (98.5%)**
|
||||
|
||||
## Missing
|
||||
|
||||
- `file.openRecent`
|
||||
- `view.outlinesSelection`
|
||||
- `view.maskOutlines`
|
||||
- `plugins.runLast`
|
||||
- `plugins.manage`
|
||||
|
||||
Menu coverage measures breadth only; see ROADMAP.md for depth and fidelity.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
# FigCraft plugins
|
||||
|
||||
A FigCraft plugin is a WebAssembly module with a small JSON manifest. It acts on the document only
|
||||
through FigCraft's commands, the same ones the menus, the CLI, the control channel and the MCP
|
||||
server run (`figcraft-cli commands` lists them), so a plugin can do what a script can do, within the
|
||||
permissions its manifest asks for, and nothing more.
|
||||
|
||||
- **Sandboxed.** Plugins run in [wasmi](https://github.com/wasmi-labs/wasmi), a pure-Rust
|
||||
WebAssembly interpreter (no JIT), in the desktop app and in the browser build alike. There is no
|
||||
WASI: no file system, network, clock or randomness. A run has an instruction budget (fuel) and a
|
||||
memory cap, so an endless loop or a runaway allocation fails that run, not the app.
|
||||
- **One undo step.** Everything a run does is one step in the undo history, named after the plugin.
|
||||
When a run fails (the plugin returns an error, traps, panics or runs out of fuel or memory), the
|
||||
document, selection and page are put back as they were.
|
||||
- **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.
|
||||
|
||||
## Using plugins
|
||||
|
||||
- **Plugins ▸ Development ▸ Import plugin from manifest…** picks a `figcraft-plugin.json`. The
|
||||
plugin is kept in the preferences and its module is read again on every run, so a rebuilt
|
||||
development plugin runs its new code without importing it again.
|
||||
- The installed plugins head the **Plugins** menu: a plugin with one command is an item, one with
|
||||
several a submenu. A command with parameters (an ellipsis after its name) first opens a small form
|
||||
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.
|
||||
|
||||
A message the plugin shows (`notify`) appears as a toast; so does a text result.
|
||||
|
||||
From scripts, the CLI and MCP the same commands apply:
|
||||
|
||||
```jsonl
|
||||
{"command": "plugin.import", "params": {"path": "plugins/tidy-names"}}
|
||||
{"command": "select.all", "params": {}}
|
||||
{"command": "plugin.run", "params": {"id": "org.figcraft.examples.tidy-names", "command": "tidy", "params": {"case": "Title Case"}}}
|
||||
```
|
||||
|
||||
`figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over
|
||||
MCP, `run_command` takes the same ids. A session started by the CLI or the MCP server starts with no
|
||||
plugins: import them in the script.
|
||||
|
||||
| Command | Params | Does |
|
||||
|---|---|---|
|
||||
| `plugin.list` | `{}` | `{plugins: [{id, name, version, description, main, api, permissions, commands, path, loaded}], last: {id, command}}` |
|
||||
| `plugin.import` | `{path}` or `{manifest, wasm}` | `path` is a `figcraft-plugin.json` or its folder. Without files (the browser, tests), pass the manifest (an object or JSON text) and the module as base64 `wasm`; such a plugin lasts for the session only. A plugin with the same id is replaced. The module is checked first. |
|
||||
| `plugin.remove` | `{id}` | Uninstalls it. |
|
||||
| `plugin.run` | `{id, command?, params?, fuel?}` | Runs a command (default: the first) with its parameters checked against the manifest and the missing ones filled with their defaults; `fuel` lowers the instruction budget. Returns `{plugin, command, output, logs, calls, fuel}`; `output` is the plugin's result, parsed as JSON when it is JSON. |
|
||||
| `plugins.runLast` | `{}` | Runs the last plugin command again. |
|
||||
| `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. |
|
||||
|
||||
## The manifest: `figcraft-plugin.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "org.figcraft.examples.swatch-grid",
|
||||
"name": "Swatch grid",
|
||||
"version": "1.0.0",
|
||||
"description": "Makes a grid of colour swatches from a palette of hex colours.",
|
||||
"main": "swatch_grid.wasm",
|
||||
"api": 1,
|
||||
"permissions": {
|
||||
"access": "write",
|
||||
"commands": ["view.get", "shape.rectangle", "text.create", "select.set"]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"id": "grid",
|
||||
"label": "Swatch grid…",
|
||||
"params": [
|
||||
{ "name": "palette", "label": "Palette", "type": "text", "default": "#FF5A5F, #00A699" },
|
||||
{ "name": "columns", "label": "Columns", "type": "number", "default": 3, "min": 1, "max": 24 },
|
||||
{ "name": "labels", "label": "Label each swatch", "type": "boolean", "default": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
|
||||
| `api` | The plugin API version, `1` (the default). A manifest asking for a newer one is refused. |
|
||||
| `permissions.access` | `read` (the default): commands that leave the document as it is. `write`: edits too. |
|
||||
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |
|
||||
| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`. |
|
||||
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean` or `select` (one of `options`). |
|
||||
|
||||
## What a plugin can't do
|
||||
|
||||
Whatever its manifest says, FigCraft refuses these calls (the plugin gets the refusal as an error
|
||||
it can handle):
|
||||
|
||||
- files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`,
|
||||
`image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`;
|
||||
- other documents and the version history: `doc.*` and `version.*` (except `doc.list` and
|
||||
`version.list`);
|
||||
- the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is
|
||||
already one undo step), and `plugin.*` / `plugins.*` (no plugin runs another);
|
||||
- commands that open a dialog or panel (for example `object.rename` without a `name`, which would
|
||||
start renaming in the layers panel): the call is undone and refused;
|
||||
- for a `read` plugin, any command that changes the document (checked before the call for commands
|
||||
that make an undo step, and after it for the rest: a change is undone and refused).
|
||||
|
||||
Limits per run: 1,000,000,000 fuel (about a second or two of plain WebAssembly in a debug build,
|
||||
far less in a release build), 64 MB of memory, 100,000 host calls (each also costs 10,000 fuel),
|
||||
16 MB per string either way, 1,000 log lines, and modules up to 16 MB.
|
||||
|
||||
## ABI (version 1)
|
||||
|
||||
Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`.
|
||||
|
||||
The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts,
|
||||
the reply buffer holds the input JSON, `input_len` bytes:
|
||||
|
||||
```json
|
||||
{"api": 1, "command": "grid", "params": {"palette": "#FF5A5F", "columns": 3, "labels": true}, "selection": [12, 15]}
|
||||
```
|
||||
|
||||
It returns `0` for success; anything else fails the run (and rolls it back), with the text passed
|
||||
to `set_output` as the error message.
|
||||
|
||||
The module may **import** these functions from the module `figcraft` (anything else, WASI
|
||||
included, is refused when the plugin is imported):
|
||||
|
||||
| Import | Signature | Does |
|
||||
|---|---|---|
|
||||
| `call` | `(cmd_ptr, cmd_len, params_ptr, params_len) -> status` | Runs a command with JSON params (empty: `{}`). `0`: done, the reply holds its result JSON. `1`: refused or failed, the reply holds the message. |
|
||||
| `selection` | `() -> len` | The reply becomes the selected layer ids, a JSON array. |
|
||||
| `reply_len` | `() -> len` | The reply's length in bytes. |
|
||||
| `reply_read` | `(ptr, len) -> copied` | Copies up to `len` bytes of the reply to `ptr`. |
|
||||
| `log` | `(ptr, len)` | A line of the run's log (returned in `logs`, and written to FigCraft's log). |
|
||||
| `notify` | `(ptr, len)` | A short message for the user (a toast in the app). |
|
||||
| `set_output` | `(ptr, len)` | The run's result (JSON or text), or its error message when it fails. |
|
||||
|
||||
A pointer or length outside the plugin's memory traps, which fails the run.
|
||||
|
||||
## Writing a plugin in Rust
|
||||
|
||||
`plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate
|
||||
`figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and
|
||||
`plugins/examples` has two plugins:
|
||||
|
||||
- **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left
|
||||
by duplicating) and optionally their letter case, inside the selection too. Two commands: one
|
||||
with a form (case, include layers inside), and a quick one without.
|
||||
- **Swatch grid** (`swatch-grid`): a grid of colour swatches from a palette of hex colours, centred in
|
||||
the view, named after their colours and optionally labelled.
|
||||
|
||||
`cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the
|
||||
`wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and
|
||||
builds them, and leaves each example's manifest and module side by side in
|
||||
`target/plugins/dist/<example>/`, ready to import.
|
||||
|
||||
A plugin of your own:
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[package]
|
||||
name = "my-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
figcraft-plugin-sdk = { path = "../figcraft/plugins/sdk" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
panic = "abort"
|
||||
strip = true
|
||||
```
|
||||
|
||||
```rust
|
||||
// src/lib.rs
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify};
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
if input.selection.is_empty() {
|
||||
return Err("Select some layers first".into());
|
||||
}
|
||||
for id in &input.selection {
|
||||
let layer = call("node.get", &json!({"id": id}))?;
|
||||
let name = layer["name"].as_str().unwrap_or_default().to_uppercase();
|
||||
call("object.rename", &json!({"ids": [id], "name": name}))?;
|
||||
}
|
||||
notify(&format!("Renamed {} layers", input.selection.len()));
|
||||
Ok(json!({"renamed": input.selection.len()}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
```
|
||||
|
||||
Build it with `cargo build --release --target wasm32-unknown-unknown`, put
|
||||
`target/wasm32-unknown-unknown/release/my_plugin.wasm` next to a `figcraft-plugin.json` whose `main`
|
||||
names it, and import the manifest. The SDK gives `call`, `selection`, `log`, `notify` and the
|
||||
`Input` (`command`, `params` with `str`/`f64`/`bool` helpers, `selection`); returning `Err` fails
|
||||
the run and rolls it back. Outside WebAssembly every host call fails, so the plugin's own logic can
|
||||
be unit-tested with `cargo test`.
|
||||
|
||||
Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`,
|
||||
import what you need from `figcraft`.
|
||||
|
||||
## Not yet
|
||||
|
||||
- Plugin-defined UI: a plugin can't yet return a form or panel of its own at run time; parameters
|
||||
come from the manifest.
|
||||
- Storage of the plugin's own: plugins can keep data on layers with `node.set {props: {pluginData:
|
||||
{key: value}}}`, but have no settings of their own between runs.
|
||||
- Distribution: no plugin browser, signing or automatic updates; plugins are imported from a
|
||||
manifest.
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swatch-grid"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tidy-names"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# FigCraft plugins written in Rust: the SDK and example plugins. A workspace of its own (not part of
|
||||
# the app's): it builds for wasm32-unknown-unknown with `cargo xtask plugins` (from the repository
|
||||
# root), which puts each example's .wasm next to its figcraft-plugin.json under target/plugins/.
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["sdk", "examples/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/figcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
figcraft-plugin-sdk = { path = "sdk" }
|
||||
serde_json = { version = "1", default-features = false, features = ["alloc"] }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
# Small modules: a plugin is loaded on every run.
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "swatch-grid"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-plugin-sdk = { workspace = true }
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"id": "org.figcraft.examples.swatch-grid",
|
||||
"name": "Swatch grid",
|
||||
"version": "1.0.0",
|
||||
"description": "Makes a grid of colour swatches from a palette of hex colours.",
|
||||
"main": "swatch_grid.wasm",
|
||||
"api": 1,
|
||||
"permissions": {
|
||||
"access": "write",
|
||||
"commands": ["view.get", "shape.rectangle", "text.create", "select.set"]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"id": "grid",
|
||||
"label": "Swatch grid…",
|
||||
"params": [
|
||||
{ "name": "palette", "label": "Palette", "type": "text", "default": "#FF5A5F, #FFB400, #00A699, #007A87, #7B0051, #3D3D3D" },
|
||||
{ "name": "columns", "label": "Columns", "type": "number", "default": 3, "min": 1, "max": 24 },
|
||||
{ "name": "size", "label": "Swatch size", "type": "number", "default": 64, "min": 8, "max": 512 },
|
||||
{ "name": "gap", "label": "Gap", "type": "number", "default": 16, "min": 0, "max": 256 },
|
||||
{ "name": "labels", "label": "Label each swatch", "type": "boolean", "default": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
//! Swatch grid: a grid of colour swatches from a palette (hex colours), centred in the view, each
|
||||
//! named after its colour and optionally labelled.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, log, notify};
|
||||
|
||||
/// Swatches made in one run, at most.
|
||||
const MAX_SWATCHES: usize = 256;
|
||||
|
||||
/// `#RGB` or `#RRGGBB` (the `#` optional) as `#RRGGBB`.
|
||||
fn hex(s: &str) -> Option<String> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
match h.len() {
|
||||
3 => Some(h.chars().flat_map(|c| [c, c]).collect::<String>()),
|
||||
6 => Some(h.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
.map(|h| format!("#{}", h.to_uppercase()))
|
||||
}
|
||||
|
||||
/// The palette's valid colours, logging the rest.
|
||||
fn palette(text: &str) -> Vec<String> {
|
||||
text.split([',', ' ', ';', '\n'])
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.filter_map(|s| {
|
||||
let c = hex(s);
|
||||
if c.is_none() {
|
||||
log(&format!("skipped `{s}`: not a hex colour"));
|
||||
}
|
||||
c
|
||||
})
|
||||
.take(MAX_SWATCHES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let colors = palette(input.str("palette").unwrap_or_default());
|
||||
if colors.is_empty() {
|
||||
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
|
||||
}
|
||||
let columns = input.f64("columns").unwrap_or(3.0).clamp(1.0, 24.0) as usize;
|
||||
let size = input.f64("size").unwrap_or(64.0).clamp(8.0, 512.0);
|
||||
let gap = input.f64("gap").unwrap_or(16.0).clamp(0.0, 256.0);
|
||||
let labels = input.bool("labels").unwrap_or(true);
|
||||
let rows = colors.len().div_ceil(columns);
|
||||
let cell_h = if labels { size + 20.0 } else { size };
|
||||
let (w, h) = (columns as f64 * (size + gap) - gap, rows as f64 * (cell_h + gap) - gap);
|
||||
// Centred in the view, on whole pixels.
|
||||
let view = call("view.get", &json!({}))?;
|
||||
let (x0, y0) = ((view["cx"].as_f64().unwrap_or(0.0) - w / 2.0).round(), (view["cy"].as_f64().unwrap_or(0.0) - h / 2.0).round());
|
||||
let mut made = Vec::new();
|
||||
for (i, color) in colors.iter().enumerate() {
|
||||
let (x, y) = (x0 + (i % columns) as f64 * (size + gap), y0 + (i / columns) as f64 * (cell_h + gap));
|
||||
let r = call("shape.rectangle", &json!({"x": x, "y": y, "width": size, "height": size, "props": {"name": color, "fill": color}}))?;
|
||||
made.extend(r["id"].as_u64());
|
||||
if labels {
|
||||
let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11}}))?;
|
||||
made.extend(t["id"].as_u64());
|
||||
}
|
||||
}
|
||||
call("select.set", &json!({"ids": made}))?;
|
||||
notify(&format!("Made {} swatches", colors.len()));
|
||||
Ok(json!({"swatches": colors.len(), "ids": made}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hex, palette};
|
||||
|
||||
#[test]
|
||||
fn palettes_parse() {
|
||||
assert_eq!(hex("#f0a"), Some("#FF00AA".into()));
|
||||
assert_eq!(hex(" 00a699 "), Some("#00A699".into()));
|
||||
assert_eq!(hex("#12345"), None);
|
||||
assert_eq!(hex("red"), None);
|
||||
assert_eq!(palette("#FF5A5F, nope;#0f0\n00A699"), vec!["#FF5A5F", "#00FF00", "#00A699"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "tidy-names"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-plugin-sdk = { workspace = true }
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "org.figcraft.examples.tidy-names",
|
||||
"name": "Tidy names",
|
||||
"version": "1.0.0",
|
||||
"description": "Cleans up layer names: extra spaces, the \"copy\" left by duplicating, and letter case.",
|
||||
"main": "tidy_names.wasm",
|
||||
"api": 1,
|
||||
"permissions": {
|
||||
"access": "write",
|
||||
"commands": ["node.get", "object.rename"]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"id": "tidy",
|
||||
"label": "Tidy names…",
|
||||
"params": [
|
||||
{ "name": "case", "label": "Case", "type": "select", "options": ["Keep", "Title Case", "lower case", "UPPER CASE"], "default": "Keep" },
|
||||
{ "name": "children", "label": "Include layers inside", "type": "boolean", "default": true }
|
||||
]
|
||||
},
|
||||
{ "id": "quick", "label": "Quick tidy" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
//! Tidy names: cleans up the selected layers' names (extra spaces, the "copy" left by duplicating)
|
||||
//! and optionally their letter case, inside the selection too.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify};
|
||||
|
||||
/// Layers looked at in one run, at most.
|
||||
const MAX_LAYERS: usize = 10_000;
|
||||
|
||||
/// `name` with single spaces, no trailing "copy" / "copy 2", in `case`.
|
||||
fn tidy(name: &str, case: &str) -> String {
|
||||
let mut words: Vec<&str> = name.split_whitespace().collect();
|
||||
loop {
|
||||
match words.as_slice() {
|
||||
[_, .., "copy"] => {
|
||||
words.pop();
|
||||
}
|
||||
[_, .., "copy", n] if n.chars().all(|c| c.is_ascii_digit()) => {
|
||||
words.truncate(words.len() - 2);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let joined = words.join(" ");
|
||||
match case {
|
||||
"Title Case" => words.iter().map(|w| title(w)).collect::<Vec<_>>().join(" "),
|
||||
"lower case" => joined.to_lowercase(),
|
||||
"UPPER CASE" => joined.to_uppercase(),
|
||||
_ => joined,
|
||||
}
|
||||
}
|
||||
|
||||
fn title(word: &str) -> String {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().chain(chars.flat_map(char::to_lowercase)).collect(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
if input.selection.is_empty() {
|
||||
return Err("Select the layers to tidy first".into());
|
||||
}
|
||||
let case = if input.command == "quick" { "Keep" } else { input.str("case").unwrap_or("Keep") };
|
||||
let inside = input.command == "quick" || input.bool("children").unwrap_or(true);
|
||||
let mut stack = input.selection.clone();
|
||||
let (mut seen, mut renamed) = (0, 0);
|
||||
while let Some(id) = stack.pop() {
|
||||
seen += 1;
|
||||
if seen > MAX_LAYERS {
|
||||
break;
|
||||
}
|
||||
let node = call("node.get", &json!({"id": id}))?;
|
||||
if inside && let Some(children) = node["children"].as_array() {
|
||||
stack.extend(children.iter().filter_map(|c| c["id"].as_u64()));
|
||||
}
|
||||
let name = node["name"].as_str().unwrap_or_default();
|
||||
let clean = tidy(name, case);
|
||||
if !clean.is_empty() && clean != name {
|
||||
call("object.rename", &json!({"ids": [id], "name": clean}))?;
|
||||
renamed += 1;
|
||||
}
|
||||
}
|
||||
notify(&match renamed {
|
||||
0 => "The names were already tidy".to_string(),
|
||||
1 => "Renamed 1 layer".to_string(),
|
||||
n => format!("Renamed {n} layers"),
|
||||
});
|
||||
Ok(json!({"renamed": renamed}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tidy;
|
||||
|
||||
#[test]
|
||||
fn names_are_tidied() {
|
||||
assert_eq!(tidy(" Card title copy 2 ", "Keep"), "Card title");
|
||||
assert_eq!(tidy("Button copy copy", "Keep"), "Button");
|
||||
assert_eq!(tidy("copy", "Keep"), "copy", "a name that is only `copy` stays");
|
||||
assert_eq!(tidy("primary BUTTON", "Title Case"), "Primary Button");
|
||||
assert_eq!(tidy("Hero Image", "lower case"), "hero image");
|
||||
assert_eq!(tidy("nav", "UPPER CASE"), "NAV");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "figcraft-plugin-sdk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Write FigCraft plugins in Rust: the plugin ABI (host functions, the entry point) wrapped with serde_json"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
//! Write FigCraft plugins in Rust.
|
||||
//!
|
||||
//! A plugin is a `cdylib` built for `wasm32-unknown-unknown`. It gets the command the user picked
|
||||
//! with its parameters and the selection, and acts on the document by calling FigCraft commands
|
||||
//! (the ids `figcraft-cli commands` lists) with JSON parameters. Everything one run does is one
|
||||
//! undo step; when the run returns an error (or panics), the document is put back.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json};
|
||||
//!
|
||||
//! fn run(input: Input) -> Result<Value> {
|
||||
//! for id in &input.selection {
|
||||
//! call("object.rename", &json!({"ids": [id], "name": "Tidy"}))?;
|
||||
//! }
|
||||
//! Ok(json!(format!("Renamed {} layers", input.selection.len())))
|
||||
//! }
|
||||
//!
|
||||
//! entry!(run);
|
||||
//! ```
|
||||
//!
|
||||
//! The SDK is `no_std` (it needs `alloc`); outside WebAssembly every host call fails, so a plugin's
|
||||
//! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath.
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
use alloc::format;
|
||||
use alloc::string::{String, ToString};
|
||||
use alloc::vec::Vec;
|
||||
|
||||
pub use serde_json::{self, Value, json};
|
||||
|
||||
/// What a plugin function returns: its output (shown or returned to the caller), or an error that
|
||||
/// fails the run and rolls the document back.
|
||||
pub type Result<T> = core::result::Result<T, String>;
|
||||
|
||||
/// The run's input.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Input {
|
||||
/// The manifest command the user picked.
|
||||
pub command: String,
|
||||
/// Its parameters, with defaults filled in.
|
||||
pub params: Value,
|
||||
/// The selected layer ids.
|
||||
pub selection: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// Parse the input JSON FigCraft hands to the plugin.
|
||||
pub fn parse(text: &str) -> Result<Input> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("input: {e}"))?;
|
||||
Ok(Input {
|
||||
command: v.get("command").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
params: v.get("params").cloned().unwrap_or(Value::Null),
|
||||
selection: ids(v.get("selection")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn str(&self, name: &str) -> Option<&str> {
|
||||
self.params.get(name).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
pub fn f64(&self, name: &str) -> Option<f64> {
|
||||
self.params.get(name).and_then(Value::as_f64).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
pub fn bool(&self, name: &str) -> Option<bool> {
|
||||
self.params.get(name).and_then(Value::as_bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer ids in a JSON array.
|
||||
fn ids(v: Option<&Value>) -> Vec<u64> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run FigCraft command `command` with `params`: its result, or why it was refused or failed.
|
||||
pub fn call(command: &str, params: &Value) -> Result<Value> {
|
||||
let params = serde_json::to_string(params).map_err(|e| e.to_string())?;
|
||||
let status = host::call(command.as_bytes(), params.as_bytes());
|
||||
let reply = String::from_utf8(host::reply()).map_err(|_| "the reply isn't UTF-8".to_string())?;
|
||||
if status != 0 {
|
||||
return Err(reply);
|
||||
}
|
||||
serde_json::from_str(&reply).map_err(|e| format!("`{command}` returned {e}"))
|
||||
}
|
||||
|
||||
/// The selected layer ids now (the input has them as they were when the run started).
|
||||
pub fn selection() -> Vec<u64> {
|
||||
host::selection();
|
||||
let reply = host::reply();
|
||||
ids(serde_json::from_slice::<Value>(&reply).ok().as_ref())
|
||||
}
|
||||
|
||||
/// A line for the run's log (returned with the result, and in FigCraft's log).
|
||||
pub fn log(line: &str) {
|
||||
host::log(line.as_bytes());
|
||||
}
|
||||
|
||||
/// A short message shown to the user.
|
||||
pub fn notify(message: &str) {
|
||||
host::notify(message.as_bytes());
|
||||
}
|
||||
|
||||
/// The body of `figcraft_run` (use [`entry!`]): read the input, run `f`, hand back its output.
|
||||
pub fn run_plugin(input_len: i32, f: fn(Input) -> Result<Value>) -> i32 {
|
||||
let input = host::reply();
|
||||
let input = input.get(..usize::try_from(input_len).unwrap_or(0)).unwrap_or(&input);
|
||||
let result = core::str::from_utf8(input).map_err(|_| "the input isn't UTF-8".to_string()).and_then(Input::parse).and_then(f);
|
||||
match result {
|
||||
Ok(v) => {
|
||||
host::set_output(serde_json::to_string(&v).unwrap_or_default().as_bytes());
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
host::set_output(e.as_bytes());
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `f: fn(Input) -> Result<Value>` as the plugin's entry point.
|
||||
#[macro_export]
|
||||
macro_rules! entry {
|
||||
($f:path) => {
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn figcraft_run(input_len: i32) -> i32 {
|
||||
$crate::run_plugin(input_len, $f)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The host functions FigCraft provides (module `figcraft`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[allow(unsafe_code)]
|
||||
mod host {
|
||||
use alloc::vec;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
mod sys {
|
||||
#[link(wasm_import_module = "figcraft")]
|
||||
unsafe extern "C" {
|
||||
pub fn call(cmd: *const u8, cmd_len: usize, params: *const u8, params_len: usize) -> i32;
|
||||
pub fn selection() -> i32;
|
||||
pub fn reply_len() -> i32;
|
||||
pub fn reply_read(ptr: *mut u8, len: usize) -> i32;
|
||||
pub fn log(ptr: *const u8, len: usize);
|
||||
pub fn notify(ptr: *const u8, len: usize);
|
||||
pub fn set_output(ptr: *const u8, len: usize);
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY (every call below): the host reads only the `len` bytes at `ptr`, which are a live
|
||||
// slice, and writes only up to `len` bytes into the buffer it's given; it checks bounds itself.
|
||||
pub fn call(cmd: &[u8], params: &[u8]) -> i32 {
|
||||
unsafe { sys::call(cmd.as_ptr(), cmd.len(), params.as_ptr(), params.len()) }
|
||||
}
|
||||
pub fn selection() -> i32 {
|
||||
unsafe { sys::selection() }
|
||||
}
|
||||
pub fn reply() -> Vec<u8> {
|
||||
let len = usize::try_from(unsafe { sys::reply_len() }).unwrap_or(0);
|
||||
let mut buf = vec![0u8; len];
|
||||
let got = unsafe { sys::reply_read(buf.as_mut_ptr(), buf.len()) };
|
||||
buf.truncate(usize::try_from(got).unwrap_or(0));
|
||||
buf
|
||||
}
|
||||
pub fn log(s: &[u8]) {
|
||||
unsafe { sys::log(s.as_ptr(), s.len()) }
|
||||
}
|
||||
pub fn notify(s: &[u8]) {
|
||||
unsafe { sys::notify(s.as_ptr(), s.len()) }
|
||||
}
|
||||
pub fn set_output(s: &[u8]) {
|
||||
unsafe { sys::set_output(s.as_ptr(), s.len()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod host {
|
||||
use alloc::vec::Vec;
|
||||
|
||||
pub fn call(_cmd: &[u8], _params: &[u8]) -> i32 {
|
||||
1
|
||||
}
|
||||
pub fn selection() -> i32 {
|
||||
0
|
||||
}
|
||||
pub fn reply() -> Vec<u8> {
|
||||
b"not running inside FigCraft".to_vec()
|
||||
}
|
||||
pub fn log(_s: &[u8]) {}
|
||||
pub fn notify(_s: &[u8]) {}
|
||||
pub fn set_output(_s: &[u8]) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn input_parses_and_host_calls_fail_off_wasm() {
|
||||
let i = Input::parse(r#"{"api":1,"command":"grid","params":{"n":3,"s":"x","b":true},"selection":[4,"x",7]}"#).unwrap_or_default();
|
||||
assert_eq!(i.command, "grid");
|
||||
assert_eq!(i.selection, alloc::vec![4, 7]);
|
||||
assert_eq!((i.f64("n"), i.str("s"), i.bool("b")), (Some(3.0), Some("x"), Some(true)));
|
||||
assert!(Input::parse("nope").is_err());
|
||||
assert!(call("node.get", &json!({"id": 1})).is_err());
|
||||
assert!(selection().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ const TABLE: &[(&str, u8)] = &[
|
|||
("layout", 2),
|
||||
("text", 2),
|
||||
("pathops", 2),
|
||||
("plugin", 2),
|
||||
("render", 3),
|
||||
("format", 3),
|
||||
("svg", 4),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@
|
|||
//! - `parity` menu-catalog coverage → docs/parity.md
|
||||
//! - `version` print the version, or `version set X.Y.Z`
|
||||
//! - `icon` regenerate the app icon PNGs/ICO from code (original artwork)
|
||||
//! - `wasm` check the core crates and web app for wasm32
|
||||
//! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples
|
||||
//! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a
|
||||
//! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and
|
||||
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…
|
||||
//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and
|
||||
//! with a synthetic design system used by 2,000 instances
|
||||
|
||||
|
|
@ -39,9 +42,10 @@ fn main() -> ExitCode {
|
|||
"version" => version(args.get(1).map(String::as_str), args.get(2).map(String::as_str)),
|
||||
"icon" => icon::generate(&root()),
|
||||
"wasm" => wasm(),
|
||||
"plugins" => plugins(),
|
||||
"bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)),
|
||||
_ => {
|
||||
eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm, bench [SIZES]");
|
||||
eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
|
||||
task == "help"
|
||||
}
|
||||
};
|
||||
|
|
@ -59,6 +63,47 @@ fn ci() -> bool {
|
|||
|
||||
fn wasm() -> bool {
|
||||
run(cargo().args(["check", "--target", "wasm32-unknown-unknown", "-p", "figcraft-engine", "-p", "figcraft-ui-egui", "-p", "figcraft-web"]))
|
||||
&& plugins()
|
||||
}
|
||||
|
||||
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
|
||||
/// `<name with _>.wasm`).
|
||||
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid"];
|
||||
|
||||
/// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest
|
||||
/// and module side by side in `<target>/plugins/dist/<example>/`.
|
||||
fn plugins() -> bool {
|
||||
let root = root();
|
||||
let manifest = root.join("plugins").join("Cargo.toml");
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("target")).join("plugins");
|
||||
let ws = |args: &[&str]| {
|
||||
let mut c = cargo();
|
||||
c.args(args).arg("--manifest-path").arg(&manifest);
|
||||
c
|
||||
};
|
||||
let wasm = ["--target", "wasm32-unknown-unknown", "--locked"];
|
||||
let ok = run(&mut ws(&["fmt", "--all", "--check"]))
|
||||
&& run(ws(&["clippy", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target).args(["--", "-D", "warnings"]))
|
||||
&& run(ws(&["test", "--workspace", "--locked"]).arg("--target-dir").arg(&target))
|
||||
&& run(ws(&["build", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target));
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
for name in EXAMPLE_PLUGINS {
|
||||
let module = format!("{}.wasm", name.replace('-', "_"));
|
||||
let dist = target.join("dist").join(name);
|
||||
let copied = std::fs::create_dir_all(&dist)
|
||||
.and_then(|()| std::fs::copy(root.join("plugins/examples").join(name).join("figcraft-plugin.json"), dist.join("figcraft-plugin.json")))
|
||||
.and_then(|_| std::fs::copy(target.join("wasm32-unknown-unknown/release").join(&module), dist.join(&module)));
|
||||
match copied {
|
||||
Ok(bytes) => eprintln!("plugins: {} ({} KB)", dist.display(), bytes / 1024),
|
||||
Err(e) => {
|
||||
eprintln!("plugins: {name}: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Engine/render timings (`figcraft-cli perf`) and UI frame timings (an ignored test driving the
|
||||
|
|
|
|||
Loading…
Reference in New Issue