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:
BFlat
2026-10-11 00:42:17 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d452da3452
commit 041b3f4e5d
38 changed files with 3110 additions and 62 deletions
+219
View File
@@ -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.