figcraft/docs/plugins.md

220 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.