Files
figcraft/crates/plugin/src/manifest.rs
T
BFlatandClaude Opus 5.5 041b3f4e5d 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>
2026-10-11 00:42:17 +07:00

338 lines
13 KiB
Rust
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.
//! 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
}