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
+1
View File
@@ -12,6 +12,7 @@ const TABLE: &[(&str, u8)] = &[
("layout", 2),
("text", 2),
("pathops", 2),
("plugin", 2),
("render", 3),
("format", 3),
("svg", 4),
+47 -2
View File
@@ -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