mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 06:16:46 -06:00
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:
@@ -220,6 +220,7 @@ pub static MENU: &[MenuItem] = &[
|
||||
// Plugins
|
||||
m!("Plugins", "Run last plugin", "plugins.runLast"),
|
||||
m!("Plugins", "Manage plugins…", "plugins.manage"),
|
||||
m!("Plugins/Development", "Import plugin from manifest…", "plugin.import"),
|
||||
// Preferences
|
||||
m!("Preferences", "Snap to geometry", "view.snapGeometry"),
|
||||
m!("Preferences", "Snap to objects", "view.snapObjects"),
|
||||
|
||||
@@ -93,6 +93,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(crate::component_codegen::specs());
|
||||
list.extend(crate::annotations::specs());
|
||||
list.extend(crate::distance::specs());
|
||||
list.extend(crate::plugins::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
||||
@@ -171,6 +171,8 @@ pub static KEYS: &[Binding] = &[
|
||||
k(Essential, "file.saveVersion", &["Alt+Cmd+S"]),
|
||||
k(Essential, "file.close", &["Cmd+W"]),
|
||||
k(Essential, "edit.findReplace", &["Cmd+F"]),
|
||||
// Ctrl+Alt+P is Pixel preview on Windows and Linux here.
|
||||
k(Essential, "plugins.runLast", &["Alt+Cmd+P"]).win(&["Ctrl+Alt+Shift+P"]),
|
||||
// Tools
|
||||
k(Tools, "tool.move", &["V"]),
|
||||
k(Tools, "tool.scale", &["K"]),
|
||||
|
||||
+31
-19
@@ -26,6 +26,7 @@ pub mod keymap;
|
||||
pub mod library;
|
||||
pub mod measure;
|
||||
pub mod params;
|
||||
pub mod plugins;
|
||||
pub mod proto_run;
|
||||
pub mod shapebuilder;
|
||||
pub mod snap;
|
||||
@@ -438,6 +439,9 @@ pub struct Prefs {
|
||||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// Installed plugins (Plugins menu), in menu order.
|
||||
#[serde(serialize_with = "plugins::persisted")]
|
||||
pub plugins: Vec<plugins::InstalledPlugin>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -453,6 +457,7 @@ impl Default for Prefs {
|
||||
theme: "light".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
plugins: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,6 +504,8 @@ pub struct Session {
|
||||
pub message: Option<String>,
|
||||
/// Library files read for the open documents.
|
||||
pub libraries: library::Cache,
|
||||
/// Plugin modules loaded in this session and the last plugin run.
|
||||
pub plugins: plugins::Runtime,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -529,6 +536,7 @@ impl Session {
|
||||
last_command: None,
|
||||
message: None,
|
||||
libraries: library::Cache::default(),
|
||||
plugins: plugins::Runtime::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,30 +788,32 @@ impl Session {
|
||||
|
||||
/// Run several commands as one undo step.
|
||||
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
|
||||
let before = self.ds()?.doc.clone();
|
||||
let sel = self.selection();
|
||||
let page = self.page()?;
|
||||
let mut out = Vec::new();
|
||||
let saved_len = self.ds()?.history.undo.len();
|
||||
for (id, p) in cmds {
|
||||
match self.execute(id, p) {
|
||||
Ok(v) => out.push(v),
|
||||
Err(e) => {
|
||||
let ds = self.ds_mut()?;
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect())
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the document, selection, page and history are put back.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let ds = self.ds_mut()?;
|
||||
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
let limit = std::mem::replace(&mut ds.history.limit, usize::MAX);
|
||||
let result = f(self);
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result };
|
||||
ds.history.limit = limit;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
if result.is_err() {
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.page = page;
|
||||
} else if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
ds.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: sel, page });
|
||||
if ds.history.undo.len() > limit.max(1) {
|
||||
ds.history.undo.remove(0);
|
||||
}
|
||||
ds.history.redo.clear();
|
||||
}
|
||||
Ok(out)
|
||||
result
|
||||
}
|
||||
|
||||
/// A summary for status displays.
|
||||
@@ -1099,6 +1109,8 @@ mod pdf_print_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_conditional_tests;
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Plugins: WebAssembly modules that act on the document through the same commands as the menus,
|
||||
//! CLI and MCP server, inside the sandbox of `figcraft-plugin`. This module is the policy: which
|
||||
//! commands a plugin may call (its manifest's permissions, and never files, other documents, the
|
||||
//! undo history, dialogs or plugins), one undo step per run, and the installed-plugin list kept in
|
||||
//! the preferences. See `docs/plugins.md`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_plugin::{Limits, PluginError};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::{CommandSpec, find_command};
|
||||
use crate::params::{get, num, string};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Param, ParamKind};
|
||||
|
||||
/// An installed plugin (in the preferences).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstalledPlugin {
|
||||
pub manifest: Manifest,
|
||||
/// Its manifest file; `None` when it was imported without one (its module then lives in this
|
||||
/// session only and isn't saved with the preferences).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Saves only the plugins imported from a manifest file (the others can't be loaded again).
|
||||
pub(crate) fn persisted<S: Serializer>(list: &[InstalledPlugin], s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.collect_seq(list.iter().filter(|p| p.path.is_some()))
|
||||
}
|
||||
|
||||
/// Per-session plugin state.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Runtime {
|
||||
/// Modules of plugins imported without a manifest file, by plugin id.
|
||||
modules: HashMap<String, Arc<Vec<u8>>>,
|
||||
/// The last run (plugin id, command id, params): "Run last plugin".
|
||||
pub last: Option<(String, String, Value)>,
|
||||
}
|
||||
|
||||
/// Command prefixes no plugin may call, whatever its permissions: files and the app (plugins get
|
||||
/// no file system), other documents, version history, the undo history, plugins themselves and
|
||||
/// nested batches (a run already is one undo step).
|
||||
const DENIED: &[&str] = &[
|
||||
"file.",
|
||||
"doc.",
|
||||
"version.",
|
||||
"app.",
|
||||
"pref.",
|
||||
"help.",
|
||||
"plugin.",
|
||||
"plugins.",
|
||||
"libraries.",
|
||||
"library.add",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"edit.repeat",
|
||||
"command.batch",
|
||||
"view.theme",
|
||||
];
|
||||
/// Read-only exceptions to [`DENIED`].
|
||||
const ALLOWED: &[&str] = &["doc.list", "version.list"];
|
||||
|
||||
/// Why a plugin may not call `command` with `params` (before running it), if it may not.
|
||||
fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
|
||||
return Some(format!("`{command}` isn't available to plugins"));
|
||||
}
|
||||
if command == "image.place" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the image as `data`".into());
|
||||
}
|
||||
if !m.permissions.allows(command) {
|
||||
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
|
||||
}
|
||||
if m.permissions.access == Access::Read && find_command(command).is_some_and(|c| c.history) {
|
||||
return Some(format!("`{command}` edits the document, and {} may only read it", m.name));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The sandbox's view of the session: commands go through [`refusal`] and run as nested groups,
|
||||
/// so a refused one leaves nothing behind.
|
||||
struct EngineHost<'s> {
|
||||
s: &'s mut Session,
|
||||
manifest: &'s Manifest,
|
||||
}
|
||||
|
||||
impl figcraft_plugin::Host for EngineHost<'_> {
|
||||
fn call(&mut self, command: &str, params: &str) -> std::result::Result<String, String> {
|
||||
let params: Value = serde_json::from_str(params).map_err(|e| format!("params for `{command}` aren't JSON: {e}"))?;
|
||||
if let Some(why) = refusal(self.manifest, command, ¶ms) {
|
||||
return Err(why);
|
||||
}
|
||||
let read_only = self.manifest.permissions.access == Access::Read;
|
||||
let ui_before = self.s.ui_requests.len();
|
||||
let r = self.s.grouped(command, |s| {
|
||||
let doc = s.ds()?.doc.clone();
|
||||
let v = s.execute(command, ¶ms)?;
|
||||
let opened_ui = s.ui_requests.len() > ui_before;
|
||||
s.ui_requests.truncate(ui_before);
|
||||
if opened_ui {
|
||||
return Err(EngineError::Disabled(command.into(), "it opens a dialog or panel, which plugins can't".into()));
|
||||
}
|
||||
if read_only && !Arc::ptr_eq(&doc, &s.ds()?.doc) {
|
||||
return Err(EngineError::Disabled(command.into(), "it changed the document, and this plugin may only read it".into()));
|
||||
}
|
||||
Ok(v)
|
||||
});
|
||||
r.map(|v| v.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn selection(&mut self) -> String {
|
||||
json!(self.s.selection()).to_string()
|
||||
}
|
||||
|
||||
fn notify(&mut self, message: &str) {
|
||||
self.s.message = Some(message.to_string());
|
||||
}
|
||||
|
||||
fn log(&mut self, line: &str) {
|
||||
log::info!("plugin {}: {line}", self.manifest.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed plugin `id`.
|
||||
fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a InstalledPlugin> {
|
||||
s.prefs.plugins.iter().find(|p| p.manifest.id == id).ok_or_else(|| bad(cmd, format!("no plugin `{id}` is installed")))
|
||||
}
|
||||
|
||||
/// Read a file of at most `max` bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
let shown = path.display();
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
if !meta.is_file() || meta.len() > max as u64 {
|
||||
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
|
||||
}
|
||||
|
||||
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
|
||||
/// path and its module.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
|
||||
let text = String::from_utf8(text).map_err(|_| bad(cmd, "the manifest isn't UTF-8 text"))?;
|
||||
let manifest = Manifest::parse(&text).map_err(|e| bad(cmd, e))?;
|
||||
let wasm = module_file(&file, &manifest)?;
|
||||
Ok((manifest, file.to_string_lossy().into_owned(), wasm))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_from_disk(cmd: &str, _path: &str) -> Result<(Manifest, String, Vec<u8>)> {
|
||||
Err(bad(cmd, "there are no files in the browser: pass `manifest` and `wasm` (base64)"))
|
||||
}
|
||||
|
||||
/// The module a manifest at `manifest_file` names.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn module_file(manifest_file: &std::path::Path, m: &Manifest) -> Result<Vec<u8>> {
|
||||
let dir = manifest_file.parent().unwrap_or(std::path::Path::new("."));
|
||||
read_file(&dir.join(&m.main), Limits::default().module_bytes)
|
||||
}
|
||||
|
||||
/// The module of an installed plugin: from the session, else read again from disk (so a rebuilt
|
||||
/// development plugin runs its new code).
|
||||
fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>> {
|
||||
if let Some(m) = s.plugins.modules.get(&p.manifest.id) {
|
||||
return Ok(m.clone());
|
||||
}
|
||||
let Some(path) = &p.path else {
|
||||
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
|
||||
}
|
||||
|
||||
fn plugin_error(name: &str, e: PluginError) -> EngineError {
|
||||
EngineError::Other(format!("{name}: {e}"))
|
||||
}
|
||||
|
||||
/// A plugin as `plugin.list` reports it.
|
||||
fn describe(s: &Session, p: &InstalledPlugin) -> Value {
|
||||
let mut v = serde_json::to_value(&p.manifest).unwrap_or_else(|_| json!({"id": p.manifest.id}));
|
||||
v["path"] = json!(p.path);
|
||||
v["loaded"] = json!(p.path.is_some() || s.plugins.modules.contains_key(&p.manifest.id));
|
||||
v
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let plugins: Vec<Value> = s.prefs.plugins.iter().map(|p| describe(s, p)).collect();
|
||||
let last = s.plugins.last.as_ref().map(|(id, command, _)| json!({"id": id, "command": command}));
|
||||
Ok(json!({"plugins": plugins, "last": last}))
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plugin.import";
|
||||
let (manifest, path, wasm) = match (string(p, "path"), get(p, "manifest")) {
|
||||
(Some(path), _) => {
|
||||
let (m, path, wasm) = load_from_disk(cmd, &path)?;
|
||||
(m, Some(path), wasm)
|
||||
}
|
||||
(None, Some(m)) => {
|
||||
let m = match m {
|
||||
Value::String(text) => Manifest::parse(text),
|
||||
other => Manifest::from_value(other),
|
||||
}
|
||||
.map_err(|e| bad(cmd, e))?;
|
||||
use base64::Engine as _;
|
||||
let b64 = string(p, "wasm").ok_or_else(|| bad(cmd, "pass the module as `wasm` (base64) with `manifest`"))?;
|
||||
if b64.len() > Limits::default().module_bytes / 3 * 4 + 4 {
|
||||
return Err(bad(cmd, "the module is too large"));
|
||||
}
|
||||
let wasm = base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`wasm`: {e}")))?;
|
||||
(m, None, wasm)
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "pass `path` (a figcraft-plugin.json or its folder), or `manifest` and `wasm`")),
|
||||
};
|
||||
figcraft_plugin::check_module(&wasm, &Limits::default()).map_err(|e| plugin_error(&manifest.name, e))?;
|
||||
let id = manifest.id.clone();
|
||||
if path.is_none() {
|
||||
s.plugins.modules.insert(id.clone(), Arc::new(wasm));
|
||||
} else {
|
||||
s.plugins.modules.remove(&id);
|
||||
}
|
||||
let entry = InstalledPlugin { manifest, path };
|
||||
let mut out = describe(s, &entry);
|
||||
let list = &mut s.prefs.plugins;
|
||||
let replaced = match list.iter_mut().find(|x| x.manifest.id == id) {
|
||||
Some(slot) => {
|
||||
*slot = entry;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
list.push(entry);
|
||||
false
|
||||
}
|
||||
};
|
||||
out["replaced"] = json!(replaced);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plugin.remove";
|
||||
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?;
|
||||
installed(s, cmd, &id)?;
|
||||
s.prefs.plugins.retain(|x| x.manifest.id != id);
|
||||
s.plugins.modules.remove(&id);
|
||||
if s.plugins.last.as_ref().is_some_and(|(last, ..)| *last == id) {
|
||||
s.plugins.last = None;
|
||||
}
|
||||
Ok(json!({"removed": id}))
|
||||
}
|
||||
|
||||
/// Run plugin `id`'s `command` with `params` as one undo step.
|
||||
pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fuel: Option<f64>) -> Result<Value> {
|
||||
let cmd = "plugin.run";
|
||||
let mut p = installed(s, cmd, id)?.clone();
|
||||
// Checked again: the preferences file may have been edited by hand.
|
||||
p.manifest = Manifest::from_value(&json!(p.manifest)).map_err(|e| bad(cmd, e))?;
|
||||
let m = &p.manifest;
|
||||
let c = m.command(command).ok_or_else(|| bad(cmd, format!("{} has no command `{}`", m.name, command.unwrap_or_default())))?;
|
||||
let params = c.resolve_params(params).map_err(|e| bad(cmd, e))?;
|
||||
let wasm = module_of(s, &p)?;
|
||||
let mut limits = Limits::default();
|
||||
if let Some(f) = fuel {
|
||||
limits.fuel = (f.max(1000.0) as u64).min(limits.fuel);
|
||||
}
|
||||
let input = json!({"api": figcraft_plugin::API_VERSION, "command": c.id, "params": params, "selection": s.selection()}).to_string();
|
||||
s.plugins.last = Some((m.id.clone(), c.id.clone(), params));
|
||||
let label = if m.commands.len() > 1 { format!("{}: {}", m.name, c.label) } else { m.name.clone() };
|
||||
let out = s.grouped(&label, |s| {
|
||||
let mut host = EngineHost { s, manifest: m };
|
||||
figcraft_plugin::run(&wasm, &input, &limits, &mut host).map_err(|e| plugin_error(&m.name, e))
|
||||
})?;
|
||||
let output = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
|
||||
Ok(json!({"plugin": m.id, "command": c.id, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}))
|
||||
}
|
||||
|
||||
fn run_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = string(p, "id").ok_or_else(|| bad("plugin.run", "`id` required (see plugin.list)"))?;
|
||||
let command = string(p, "command");
|
||||
run(s, &id, command.as_deref(), get(p, "params").unwrap_or(&Value::Null), num(p, "fuel"))
|
||||
}
|
||||
|
||||
fn run_last(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some((id, command, params)) = s.plugins.last.clone() else {
|
||||
return Err(EngineError::Disabled("plugins.runLast".into(), "no plugin has run yet".into()));
|
||||
};
|
||||
run(s, &id, Some(&command), ¶ms, None)
|
||||
}
|
||||
|
||||
fn manage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.request_ui("dialog.plugins", json!({}));
|
||||
list(s, p)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("plugin.list", "List plugins", "{} → {plugins: [{id, name, version, main, permissions, commands, path, loaded}], last}", false, list),
|
||||
spec!(
|
||||
"plugin.import",
|
||||
"Import plugin from manifest…",
|
||||
"{path (figcraft-plugin.json or its folder) | manifest (object or JSON text), wasm (base64)} replaces a plugin with the same id",
|
||||
false,
|
||||
import
|
||||
),
|
||||
spec!("plugin.remove", "Remove plugin", "{id}", false, remove),
|
||||
spec!(
|
||||
"plugin.run",
|
||||
"Run plugin",
|
||||
"{id, command? (default: the first), params? (the command's parameters), fuel? (lower instruction budget)} one undo step → {output, logs, calls, fuel}",
|
||||
false,
|
||||
run_cmd
|
||||
),
|
||||
spec!("plugins.runLast", "Run last plugin", "{}", false, run_last),
|
||||
spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Plugins: runs are one undo step and roll back when they fail, permissions and the host's own
|
||||
//! limits are enforced, fuel and memory are capped, and junk never gets in. Test plugins are
|
||||
//! assembled from WAT text: each makes a fixed list of command calls.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::components::tests::{ex, idv, node};
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
/// What a test plugin does when a call is refused: fail the run (status 1), or carry on and report
|
||||
/// the refusal as its output.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum OnRefusal {
|
||||
Fail,
|
||||
Report,
|
||||
}
|
||||
|
||||
/// `s` as a WAT string literal.
|
||||
fn lit(s: &str) -> String {
|
||||
s.bytes()
|
||||
.map(|b| if b.is_ascii_graphic() && b != b'"' && b != b'\\' || b == b' ' { (b as char).to_string() } else { format!("\\{b:02x}") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A module that reads the selection, makes `calls` in order and outputs the selection it read
|
||||
/// (or the first refusal). `tail` runs after the calls (an endless loop, a trap…).
|
||||
fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
let mut data = String::new();
|
||||
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
|
||||
let mut at = 0usize;
|
||||
for (cmd, params) in calls {
|
||||
let p = params.to_string();
|
||||
data += &format!("(data (i32.const {at}) \"{}\") (data (i32.const {}) \"{}\")\n", lit(cmd), at + cmd.len(), lit(&p));
|
||||
let status = if on_refusal == OnRefusal::Fail { 1 } else { 0 };
|
||||
body += &format!(
|
||||
"(if (call $call (i32.const {at}) (i32.const {}) (i32.const {}) (i32.const {}))
|
||||
(then (call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const {status}))))\n",
|
||||
cmd.len(),
|
||||
at + cmd.len(),
|
||||
p.len()
|
||||
);
|
||||
at += cmd.len() + p.len();
|
||||
}
|
||||
assert!(at < 40000);
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "selection" (func $selection (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "notify" (func $notify (param i32 i32)))
|
||||
(memory (export "memory") 2)
|
||||
{data}
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) (local $sel i32)
|
||||
{body}
|
||||
{tail}
|
||||
(call $set_output (i32.const 60000) (local.get $sel))
|
||||
(i32.const 0)))"#
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn manifest(id: &str, access: &str, allow: &[&str]) -> Value {
|
||||
json!({"id": id, "name": format!("Plugin {id}"), "version": "1.0.0", "main": "plugin.wasm",
|
||||
"permissions": {"access": access, "commands": allow}})
|
||||
}
|
||||
|
||||
/// Install a plugin from a manifest and module bytes (no files).
|
||||
fn install(s: &mut Session, manifest: Value, wasm: &[u8]) {
|
||||
ex(s, "plugin.import", json!({"manifest": manifest, "wasm": b64(wasm)}));
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 10, "height": 10})))
|
||||
}
|
||||
|
||||
fn layer_count(s: &Session) -> usize {
|
||||
let ds = s.ds().unwrap();
|
||||
ds.doc.node(ds.page).unwrap().children.len()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn run_err(s: &mut Session, id: &str) -> String {
|
||||
match s.execute("plugin.run", &json!({"id": id, "fuel": 2_000_000})) {
|
||||
Ok(v) => panic!("ran: {v}"),
|
||||
Err(e) => {
|
||||
assert!(!matches!(e, EngineError::Internal { .. }), "{e}");
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let calls = [
|
||||
("object.rename", json!({"name": "Tidy"})),
|
||||
("shape.rectangle", json!({"x": 50, "y": 0, "width": 20, "height": 20})),
|
||||
("node.get", json!({"id": a})),
|
||||
];
|
||||
install(&mut s, manifest("org.test.tidy", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
|
||||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
|
||||
assert_eq!(out["output"], json!([a.0]), "the plugin read the selection");
|
||||
assert_eq!(out["calls"], json!(4));
|
||||
assert_eq!(node(&s, a).name, "Tidy");
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
|
||||
assert_eq!(s.ds().unwrap().history.undo.last().unwrap().label, "Plugin org.test.tidy");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
assert_eq!(layer_count(&s), layers);
|
||||
// Run last plugin runs it again.
|
||||
ex(&mut s, "plugins.runLast", json!({}));
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(ex(&mut s, "plugin.list", json!({}))["last"], json!({"id": "org.test.tidy", "command": "run"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_plugins_and_allowlists_are_enforced() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
// Read access: queries run, edits are refused and the run fails, leaving nothing.
|
||||
install(
|
||||
&mut s,
|
||||
manifest("org.test.reader", "read", &[]),
|
||||
&script(&[("node.get", json!({"id": a})), ("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))], OnRefusal::Fail, ""),
|
||||
);
|
||||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
let e = run_err(&mut s, "org.test.reader");
|
||||
assert!(e.contains("may only read"), "{e}");
|
||||
assert_eq!((layer_count(&s), undo_len(&s)), (layers, undo));
|
||||
// Write access limited to some commands.
|
||||
install(
|
||||
&mut s,
|
||||
manifest("org.test.narrow", "write", &["node.*", "select.set"]),
|
||||
&script(&[("node.get", json!({"id": a})), ("object.rename", json!({"name": "x"}))], OnRefusal::Fail, ""),
|
||||
);
|
||||
let e = run_err(&mut s, "org.test.narrow");
|
||||
assert!(e.contains("asked permission for"), "{e}");
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_dialogs_history_and_plugins_are_off_limits() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-plugin-deny-{}", std::process::id()));
|
||||
let target = dir.join("stolen.figcraft").to_string_lossy().into_owned();
|
||||
let refused = [
|
||||
("file.saveAs", json!({"path": target})),
|
||||
("file.open", json!({"path": "C:/Windows/win.ini"})),
|
||||
("image.place", json!({"path": "secret.png"})),
|
||||
("library.add", json!({"path": "lib.figcraft"})),
|
||||
("plugin.run", json!({"id": "org.test.deny"})),
|
||||
("plugin.remove", json!({"id": "org.test.deny"})),
|
||||
("edit.undo", json!({})),
|
||||
("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})),
|
||||
("doc.switch", json!({"index": 0})),
|
||||
("pref.keepTool", json!({})),
|
||||
// Without a name it would open inline renaming in the layers panel: a UI request.
|
||||
("object.rename", json!({})),
|
||||
];
|
||||
for (cmd, params) in refused {
|
||||
install(&mut s, manifest("org.test.deny", "write", &[]), &script(&[(cmd, params.clone())], OnRefusal::Report, ""));
|
||||
let (doc, undo) = (s.ds().unwrap().doc.clone(), undo_len(&s));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.deny"}));
|
||||
let msg = out["output"].as_str().unwrap_or_default().to_string();
|
||||
assert!(msg.contains("plugins") || msg.contains("dialog"), "{cmd}: {out}");
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc), "{cmd} left the document as it was");
|
||||
assert_eq!(undo_len(&s), undo, "{cmd}");
|
||||
assert!(s.ui_requests.is_empty(), "{cmd}: {:?}", s.ui_requests);
|
||||
}
|
||||
assert!(!dir.exists(), "nothing was written");
|
||||
assert!(ex(&mut s, "plugin.list", json!({}))["plugins"].as_array().unwrap().len() == 1, "the plugin didn't remove itself");
|
||||
// The read-only exceptions are open.
|
||||
install(&mut s, manifest("org.test.reads", "read", &[]), &script(&[("doc.list", json!({})), ("version.list", json!({}))], OnRefusal::Fail, ""));
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.reads"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traps_endless_loops_and_memory_hogs_roll_back() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))];
|
||||
let cases = [
|
||||
("org.test.trap", "unreachable", "crashed"),
|
||||
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
|
||||
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
|
||||
];
|
||||
for (id, tail, want) in cases {
|
||||
install(&mut s, manifest(id, "write", &[]), &script(&edit, OnRefusal::Fail, tail));
|
||||
let (layers, undo, sel) = (layer_count(&s), undo_len(&s), s.selection());
|
||||
let started = web_time::Instant::now();
|
||||
let e = run_err(&mut s, id);
|
||||
assert!(e.contains(want), "{id}: {e}");
|
||||
assert!(started.elapsed().as_secs() < 20, "{id} was stopped promptly");
|
||||
assert_eq!((layer_count(&s), undo_len(&s), s.selection()), (layers, undo, sel), "{id} rolled back");
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
// A module that asks for more memory than the cap up front never starts.
|
||||
let hog =
|
||||
wat::parse_str(r#"(module (memory (export "memory") 3000) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#).unwrap();
|
||||
install(&mut s, manifest("org.test.hog", "write", &[]), &hog);
|
||||
assert!(run_err(&mut s, "org.test.hog").contains("memory limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_defaults_and_notifications_reach_the_plugin() {
|
||||
let mut s = Session::new();
|
||||
let echo = wat::parse_str(
|
||||
r#"(module
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "notify" (func $notify (param i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "Done!")
|
||||
(func (export "figcraft_run") (param $n i32) (result i32)
|
||||
(call $set_output (i32.const 100) (call $reply_read (i32.const 100) (local.get $n)))
|
||||
(call $notify (i32.const 0) (i32.const 5))
|
||||
(i32.const 0)))"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = manifest("org.test.echo", "read", &[]);
|
||||
m["commands"] = json!([
|
||||
{"id": "first", "label": "First"},
|
||||
{"id": "grid", "label": "Swatch grid", "params": [{"name": "columns", "type": "number", "default": 4, "min": 1, "max": 8}, {"name": "label", "type": "text"}]}
|
||||
]);
|
||||
install(&mut s, m, &echo);
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.echo", "command": "grid", "params": {"columns": 99}}));
|
||||
assert_eq!(out["output"], json!({"api": 1, "command": "grid", "params": {"columns": 8.0, "label": ""}, "selection": []}));
|
||||
assert_eq!(s.message.as_deref(), Some("Done!"));
|
||||
assert_eq!(undo_len(&s), 0, "a run that edits nothing adds no undo step");
|
||||
assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.echo"}))["output"]["command"], "first", "the first command by default");
|
||||
for bad in [
|
||||
json!({"id": "org.test.echo", "command": "nope"}),
|
||||
json!({"id": "org.test.echo", "command": "grid", "params": {"columns": "many"}}),
|
||||
json!({"id": "missing"}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(matches!(s.execute("plugin.run", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
assert!(s.execute("plugins.runLast", &json!({})).is_ok());
|
||||
ex(&mut s, "plugin.remove", json!({"id": "org.test.echo"}));
|
||||
assert!(s.execute("plugins.runLast", &json!({})).is_err(), "the removed plugin is forgotten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_manifests_and_modules_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let ok = script(&[], OnRefusal::Fail, "");
|
||||
let junk = [
|
||||
json!({}),
|
||||
json!({"manifest": {}}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": "!!!not base64"}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": b64(b"\0asm\x01\0\0\0garbage")}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": b64(b"")}),
|
||||
json!({"manifest": manifest("org.test.x", "everything", &[]), "wasm": b64(&ok)}),
|
||||
json!({"manifest": manifest("../escape", "write", &[]), "wasm": b64(&ok)}),
|
||||
json!({"manifest": "{ not json", "wasm": b64(&ok)}),
|
||||
json!({"manifest": 7, "wasm": b64(&ok)}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[])}),
|
||||
json!({"path": "Z:/definitely/not/here/figcraft-plugin.json"}),
|
||||
json!({"path": 5}),
|
||||
json!({"manifest": manifest("org.test.x", "write", &[]), "wasm": "A".repeat(23_000_000)}),
|
||||
];
|
||||
for j in &junk {
|
||||
match s.execute("plugin.import", j) {
|
||||
Ok(v) => panic!("imported {v}"),
|
||||
Err(EngineError::Internal { msg, .. }) => panic!("panicked: {msg}"),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(ex(&mut s, "plugin.list", json!({}))["plugins"], json!([]));
|
||||
// Every plugin command with junk parameters (the engine-wide sweep runs them too).
|
||||
install(&mut s, manifest("org.test.ok", "write", &[]), &ok);
|
||||
for cmd in ["plugin.list", "plugin.import", "plugin.remove", "plugin.run", "plugins.runLast", "plugins.manage"] {
|
||||
for j in [
|
||||
json!(null),
|
||||
json!([1]),
|
||||
json!({"id": 5, "command": [], "params": "x", "fuel": "NaN"}),
|
||||
json!({"id": "org.test.ok", "params": [1], "fuel": -1}),
|
||||
] {
|
||||
if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd, &j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A temporary folder removed when dropped (also when a test fails).
|
||||
struct TempDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_plugins_load_from_a_manifest_and_reload_their_module() {
|
||||
let dir = TempDir(std::env::temp_dir().join(format!("figcraft-plugin-dev-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let write_module = |calls: &[(&str, Value)]| std::fs::write(dir.0.join("plugin.wasm"), script(calls, OnRefusal::Fail, "")).unwrap();
|
||||
std::fs::write(dir.0.join("figcraft-plugin.json"), manifest("org.test.dev", "write", &[]).to_string()).unwrap();
|
||||
write_module(&[("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))]);
|
||||
let mut s = Session::new();
|
||||
// The folder or the manifest file itself.
|
||||
let out = ex(&mut s, "plugin.import", json!({"path": dir.0.to_string_lossy()}));
|
||||
assert_eq!(out["id"], "org.test.dev");
|
||||
assert_eq!(out["replaced"], false);
|
||||
let manifest_path = dir.0.join("figcraft-plugin.json").to_string_lossy().into_owned();
|
||||
assert_eq!(ex(&mut s, "plugin.import", json!({"path": manifest_path}))["replaced"], true);
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.dev"}));
|
||||
assert_eq!(layer_count(&s), 1);
|
||||
// A rebuilt module runs without importing again.
|
||||
write_module(&[
|
||||
("shape.ellipse", json!({"x": 0, "y": 0, "width": 5, "height": 5})),
|
||||
("shape.ellipse", json!({"x": 9, "y": 0, "width": 5, "height": 5})),
|
||||
]);
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.dev"}));
|
||||
assert_eq!(layer_count(&s), 3);
|
||||
// Only plugins with a manifest file are saved with the preferences.
|
||||
install(&mut s, manifest("org.test.memory", "read", &[]), &script(&[], OnRefusal::Fail, ""));
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
let ids: Vec<&str> = saved["plugins"].as_array().unwrap().iter().filter_map(|p| p["manifest"]["id"].as_str()).collect();
|
||||
assert_eq!(ids, vec!["org.test.dev"]);
|
||||
let back: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(back.plugins.len(), 1);
|
||||
// A missing module fails the run cleanly.
|
||||
std::fs::remove_file(dir.0.join("plugin.wasm")).unwrap();
|
||||
assert!(run_err(&mut s, "org.test.dev").contains("plugin.wasm"));
|
||||
ex(&mut s, "plugin.remove", json!({"id": "org.test.dev"}));
|
||||
assert_eq!(ex(&mut s, "plugin.list", json!({}))["plugins"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() {
|
||||
let mut s = Session::new();
|
||||
s.ds_mut().unwrap().history.limit = 3;
|
||||
let first = rect(&mut s, 0.0);
|
||||
for x in [20.0, 40.0] {
|
||||
rect(&mut s, x);
|
||||
}
|
||||
assert_eq!(undo_len(&s), 3);
|
||||
let edits: Vec<(&str, Value)> = (0..5).map(|i| ("shape.ellipse", json!({"x": i * 10, "y": 50, "width": 5, "height": 5}))).collect();
|
||||
install(&mut s, manifest("org.test.many", "write", &[]), &script(&edits, OnRefusal::Fail, ""));
|
||||
let oldest = s.ds().unwrap().history.undo[1].doc.clone();
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.many"}));
|
||||
let undo = &s.ds().unwrap().history.undo;
|
||||
assert_eq!(undo.len(), 3, "the run is one step; only the oldest step made room for it");
|
||||
assert!(std::sync::Arc::ptr_eq(&undo[0].doc, &oldest));
|
||||
assert!(s.ds().unwrap().doc.contains(first));
|
||||
// A failing run leaves the history alone.
|
||||
install(&mut s, manifest("org.test.fail", "write", &[]), &script(&edits, OnRefusal::Fail, "unreachable"));
|
||||
let before: Vec<_> = s.ds().unwrap().history.undo.iter().map(|e| e.doc.clone()).collect();
|
||||
run_err(&mut s, "org.test.fail");
|
||||
let after = &s.ds().unwrap().history.undo;
|
||||
assert!(before.len() == after.len() && before.iter().zip(after).all(|(a, b)| std::sync::Arc::ptr_eq(a, &b.doc)));
|
||||
// A manifest edited in the preferences file is checked again before it runs.
|
||||
s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into();
|
||||
assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
Reference in New Issue
Block a user