mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 04:54:36 +00:00
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>
338 lines
13 KiB
Rust
338 lines
13 KiB
Rust
//! 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
|
||
}
|