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
+15
View File
@@ -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 }
]
}
]
}
+82
View File
@@ -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"]);
}
}
+15
View File
@@ -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" }
]
}
+87
View File
@@ -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");
}
}