Files
vectorcraft/crates/engine/src/cmd/plugin.rs
T
dotCooCoo ed5de5edcb Plug-ins: a plug-in adds menu commands of its own under Object › Plug-ins
A filter or live effect may declare up to 32 `commands` in its manifest,
each `{id, label, params?}`. While the plug-in is installed each one is the
command `plugin.<id>.<command>`: listed by `command.list` and in the
plug-in's `plugin.list` entry, run through `run`, MCP and `command.batch`
with its parameters checked before the plug-in runs, and recorded in
actions. A run goes through `vc_run` on the selection with
`_context.mode` "command" and `_context.command`, under the plug-in's
permissions, as one undo step named after the command. A failing run
leaves the document as it was, and its error starts with the plug-in's
name and the command's label. Removing or replacing the plug-in removes
its commands.

In the app, a plug-in with commands is a submenu named after it under
Object › Plug-ins (Effect › Plug-ins for a live effect), holding its own
item and then its commands; a `menu` key in a manifest is ignored. A
command with parameters opens a dialog for them from its menu item or a
shortcut. Edit › Keyboard Shortcuts lists the commands for the user to
give a shortcut; a manifest cannot set one. Labels and names are shown
with control and bidirectional characters escaped, and a malformed entry
is refused by name when the module is installed.

Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
2026-11-07 17:32:54 -08:00

724 lines
36 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.
//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`).
//!
//! Plug-ins are installed with `plugin.install` (kept across restarts in the apps' plug-in folder,
//! [`Session::attach_plugin_store`]) or from every `*.wasm` in the Additional Plug-ins Folder
//! preference, and listed with `plugin.list` / `plugin.info`. Object filters run
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
//! plug-in that fails leaves the document untouched), and read and write the data they keep with
//! the document ([`vectorcraft_doc::plugin_data`], theirs alone); live-effect plug-ins are applied
//! like any effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`;
//! exporters add a file format that `document.export` writes (`fileio::plugin_formats`).
//!
//! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing,
//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`),
//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with
//! Cancel ([`FilterRun`]).
//!
//! A plug-in whose manifest lists permissions installs once the user grants them
//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and
//! a filter does only what they allow: without `doc.read` it is given no objects, without
//! `doc.write` an output that changes the document is refused, and without `state` it neither
//! reads nor writes the data it keeps with the document.
//!
//! A filter or a live effect may add menu commands of its own (its manifest's `commands`). Each
//! is the command `plugin.<plug-in id>.<command id>` while the plug-in is installed
//! ([`command_specs`], which [`super::find_command`] and the command listings read): under
//! Object › Plug-ins › the plug-in's name (Effect › Plug-ins for a live effect's), journaled so
//! actions record it, and run as a filter run of the plug-in ([`run_command`]) with the command's
//! id in `_context`, its own checked parameters and the plug-in's permissions, as one undo step.
use std::sync::{Arc, Mutex, RwLock};
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Effect, Node};
use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry};
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
use vectorcraft_plugins::permissions::{self, Grant, Permission};
/// What a plug-in error looks like to the user. A run the caller cancelled changed nothing.
fn err(e: vectorcraft_plugins::Error) -> EngineError {
match e {
vectorcraft_plugins::Error::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
}
}
/// JSON description of an installed plug-in.
pub fn describe(p: &Plugin) -> Value {
let m = p.manifest();
let mut v = json!({
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
"size": p.size(), "source": p.source(),
"permissions": permissions::names(&m.permissions()), "hash": p.hash(),
});
match m.kind {
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
Kind::Exporter => {
v["format"] = json!(export::format_id(&m.id));
v["extensions"] = json!(m.extensions);
}
Kind::Filter => {}
}
if m.budget == Budget::Heavy {
v["budget"] = json!("heavy");
}
if !m.commands.is_empty() {
let commands: Vec<Value> = m
.commands
.iter()
.map(|c| {
let run = m.for_command(c);
json!({"id": c.id, "command": command_id(&m.id, &c.id), "label": c.shown_label(), "params": run.params_doc(),
"paramsSchema": serde_json::to_value(&run).ok().and_then(|v| v.get("params").cloned())})
})
.collect();
v["commands"] = json!(commands);
}
v
}
/// The plug-in's own parameters from command params: `params` if given, else the top-level keys
/// minus the ones the command itself uses.
fn plugin_params(p: &Value) -> Value {
if let Some(v) = p.get("params") {
return v.clone();
}
match p {
Value::Object(m) => {
Value::Object(m.iter().filter(|(k, _)| !matches!(k.as_str(), "id" | "ids")).map(|(k, v)| (k.clone(), v.clone())).collect())
}
_ => Value::Object(Map::new()),
}
}
fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?;
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
}
/// One run of an object filter, split so the plug-in can compute away from the session: made from
/// the active document ([`FilterRun::prepare`]), run on any thread ([`FilterRun::run`] touches no
/// document), then handed in ([`FilterRun::hand_in`]) for `plugin.run` to apply instead of running
/// the plug-in again. The app runs a heavy filter ([`Budget::Heavy`]) this way, in the background
/// with Cancel, and the edit itself stays a `plugin.run`: journaled, guarded and undone as one step
/// like any other command.
pub struct FilterRun {
plugin: Arc<Plugin>,
/// The plug-in command it runs (`plugin.<id>.<command>`), if it isn't `plugin.run`'s run.
command: Option<Command>,
input: Vec<u8>,
params: Vec<u8>,
/// The objects the plug-in is given, and the container new objects go in.
inputs: Vec<NodeId>,
parent: Option<NodeId>,
/// The document it was made from, and that document's revision then.
document: (u64, u64),
}
/// A filter's output handed in by a [`FilterRun`] made away from `plugin.run`, with what the run
/// was made of: only a run of the same installation of the plug-in on the same input with the same
/// parameters takes it (a plug-in's output depends on nothing else).
pub(crate) struct HandedIn {
serial: u64,
input: Vec<u8>,
params: Vec<u8>,
output: Vec<u8>,
}
impl FilterRun {
/// The run `plugin.run`'s params `p` ask for, on the active document: the plug-in (a filter),
/// its checked parameters and `_context`, and the selection's objects.
pub fn prepare(s: &Session, p: &Value) -> Result<FilterRun> {
const C: &str = "plugin.run";
let plugin = plugin_by_id(C, p)?;
let m = plugin.manifest();
match m.kind {
Kind::Filter => {}
Kind::Effect => {
return Err(bad(
C,
format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id)),
));
}
Kind::Exporter => {
return Err(bad(
C,
format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)),
));
}
}
Self::prepare_with(s, plugin, None, C, p)
}
/// The run of `plugin`, or of its `command`, that command `c`'s params `p` ask for, on the
/// active document. Bad parameters fail before anything runs.
fn prepare_with(s: &Session, plugin: Arc<Plugin>, command: Option<Command>, c: &str, p: &Value) -> Result<FilterRun> {
let m = plugin.manifest();
let resolved = match &command {
Some(cmd) => m.for_command(cmd).resolve_params(&plugin_params(p)),
None => m.resolve_params(&plugin_params(p)),
};
let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?);
let may = m.permissions();
let st = s.doc()?;
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
// Without `doc.read` a filter is given no objects: it can only add new ones.
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
let parent = st.insertion_parent();
let bounds = st.doc.bounds_of(&inputs, false);
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
// What the plug-in keeps with the document, as it last wrote it.
let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null);
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
// A command run says which command it is.
if let Some(cmd) = &command {
params["_context"]["mode"] = json!("command");
params["_context"]["command"] = json!(cmd.id);
}
let input = objects::encode_input(&st.doc, &inputs);
let params = serde_json::to_vec(&params).map_err(|e| bad(c, e.to_string()))?;
let document = (st.uid, st.revision);
Ok(FilterRun { plugin, command, input, params, inputs, parent, document })
}
/// The plug-in, for its name and budget.
pub fn plugin(&self) -> &Arc<Plugin> {
&self.plugin
}
/// The objects it gives the plug-in: `plugin.run`'s `ids` that make this same run again.
pub fn inputs(&self) -> &[NodeId] {
&self.inputs
}
/// The document it was made from (`DocState::uid`).
pub fn document(&self) -> u64 {
self.document.0
}
/// Does it still apply: is the active document the one it was made from, unchanged since, and
/// the plug-in still installed as it was?
pub fn current(&self, s: &Session) -> bool {
let installed = registry::get(self.plugin.id()).is_some_and(|p| p.serial() == self.plugin.serial());
installed && s.active().is_some_and(|st| (st.uid, st.revision) == self.document)
}
/// Runs the plug-in → its output, or why it failed or stopped. It touches no document, so it
/// can run on any thread; inside a [`vectorcraft_doc::cancel::scope`] Cancel stops it between
/// two slices of its budget.
pub fn run(&self) -> Result<Vec<u8>> {
self.plugin.run(&self.input, &self.params).map_err(err)
}
/// Hands in `output`, which [`FilterRun::run`] returned: the next `plugin.run` that makes this
/// same run (same plug-in, `ids` and parameters, the document unchanged) applies it instead of
/// running the plug-in. Any other run drops it.
pub fn hand_in(self, s: &mut Session, output: Vec<u8>) {
s.plugin_output = Some(HandedIn { serial: self.plugin.serial(), input: self.input, params: self.params, output });
}
/// Applies the plug-in's `output` to the active document as one undo step: its objects, and what
/// it keeps with the document from now on. An output its permissions don't allow is refused,
/// and the document stays as it was.
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
let m = self.plugin.manifest();
let may = m.permissions();
let out = objects::decode_output(output).map_err(err)?;
if out.document_data.is_some() && !may.contains(&Permission::State) {
return Err(not_granted(&m.name, Permission::State));
}
let may_write = may.contains(&Permission::DocWrite);
let (inputs, parent) = (self.inputs, self.parent);
// A command's undo step is named after the command.
let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label);
let ids = s.edit(&label, |d, sel| {
let before = (!may_write).then(|| d.layers.clone());
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
if before.is_some_and(|b| b != d.layers) {
return Err(not_granted(&m.name, Permission::DocWrite));
}
if let Some(data) = out.document_data {
d.set_plugin_data(&m.id, data);
}
sel.set(ids.iter().copied().filter(|&id| d.node(id).is_some_and(objects::selectable)));
Ok(ids)
})?;
let mut out = json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
if let Some(cmd) = &self.command {
out["command"] = json!(cmd.id);
}
Ok(out)
}
}
/// The error of a filter `name` whose output needs `permission`, which it wasn't granted.
fn not_granted(name: &str, permission: Permission) -> EngineError {
let (name, p) = (permissions::display_text(name, permissions::MAX_SHOWN_NAME), permission.name());
err(vectorcraft_plugins::Error::Output(format!("“{name}” wasn't granted `{p}`, which its output needs: nothing was changed")))
}
/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its
/// parameters and `_context` (what it keeps with the document included), and applies its output as
/// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with
/// what it keeps with the document from now on: the output handed in for this very run when there
/// is one ([`FilterRun::hand_in`]), else the plug-in's, run now. → `{plugin, ids}`: what it made or
/// changed, which is then selected.
fn run(s: &mut Session, p: &Value) -> Result<Value> {
// An output handed in is for this run only (the app hands it in just before), whatever it does.
let handed = s.plugin_output.take();
let r = FilterRun::prepare(s, p)?;
// Handed in for this very run (the app ran it in the background), it is what running the
// plug-in again would return.
let handed = handed.filter(|h| (h.serial, &h.input, &h.params) == (r.plugin.serial(), &r.input, &r.params));
let output = match handed {
Some(h) => h.output,
None => r.run()?,
};
r.finish(s, &output)
}
/// The command id of plug-in `plugin`'s command `command`: `plugin.<plugin>.<command>`.
pub fn command_id(plugin: &str, command: &str) -> String {
format!("{}{plugin}.{command}", vectorcraft_plugins::PREFIX)
}
/// Is `id` shaped as a plug-in command's id (`plugin.<plug-in id>.<command id>`)? This module's
/// own `plugin.*` commands have no dot after the prefix, and a command id has none.
pub fn is_command_id(id: &str) -> bool {
id.strip_prefix(vectorcraft_plugins::PREFIX).is_some_and(|rest| rest.contains('.'))
}
/// The installed plug-in and its command that command id `id` names.
pub fn command_of(id: &str) -> Option<(Arc<Plugin>, Command)> {
let (plugin, command) = id.strip_prefix(vectorcraft_plugins::PREFIX)?.rsplit_once('.')?;
let p = registry::get(plugin)?;
let c = p.manifest().command(command)?.clone();
Some((p, c))
}
/// The plug-in commands of the installed plug-ins, the registry revision they were listed at, and
/// a count that changes whenever the list does ([`commands_generation`]).
struct Listed {
revision: Option<u64>,
generation: u64,
specs: Vec<&'static CommandSpec>,
}
static LISTED: RwLock<Listed> = RwLock::new(Listed { revision: None, generation: 0, specs: Vec::new() });
/// Every command spec made for a plug-in command, made once: specs hold static strings, so memory
/// stays bounded by the plug-in commands ever installed.
static MADE: Mutex<Vec<&'static CommandSpec>> = Mutex::new(Vec::new());
/// The commands the installed plug-ins add, in plug-in id order and then in each manifest's order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let revision = registry::revision();
if let Ok(l) = LISTED.read()
&& l.revision == Some(revision)
{
return l.specs.clone();
}
let specs: Vec<&'static CommandSpec> =
registry::list().iter().flat_map(|p| p.manifest().commands.iter().map(|c| spec_of(p, c)).collect::<Vec<_>>()).collect();
let mut l = LISTED.write().unwrap_or_else(|e| e.into_inner());
if l.specs.len() != specs.len() || l.specs.iter().zip(&specs).any(|(a, b)| !std::ptr::eq(*a, *b)) {
l.generation += 1;
}
l.revision = Some(revision);
l.specs.clone_from(&specs);
specs
}
/// Changes whenever the plug-in commands [`command_specs`] lists change, for caches built from
/// them (the keyboard shortcuts' catalogue).
pub fn commands_generation() -> u64 {
command_specs();
LISTED.read().map(|l| l.generation).unwrap_or_else(|e| e.into_inner().generation)
}
/// The spec of installed plug-in command `id`.
pub fn find_spec(id: &str) -> Option<&'static CommandSpec> {
if !is_command_id(id) {
return None;
}
command_specs().into_iter().find(|c| c.id == id)
}
/// The command spec of plug-in `p`'s command `c`: its id, its label as menus show it (escaped and
/// capped), its menu (Object › Plug-ins › the plug-in's name, or Effect › Plug-ins › it for a live
/// effect: never a menu the manifest names) and its params description.
fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec {
let m = p.manifest();
let id = command_id(&m.id, &c.id);
let label = c.shown_label();
let top = if m.kind == Kind::Effect { "Effect" } else { "Object" };
let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME);
let mut fields = String::from("ids?: [..] (default: the selection), params?: {…} (its parameters; or give them as top-level keys)");
let doc = m.for_command(c).params_doc();
if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) {
fields.push_str(", ");
fields.push_str(own);
}
let params = format!(
"{{{fields}}} run “{label}”, a command of plug-in {}, on the selected paths and compound paths as plugin.run runs a filter, with _context.command {:?}: one undo step → {{plugin, command, ids}}",
m.id, c.id
);
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
let same = |s: &&&'static CommandSpec| s.id == id && s.label == label && s.menu == [top, "Plug-ins", name.as_str()] && s.params == params;
if let Some(s) = made.iter().find(same) {
return s;
}
let menu: &'static [&'static str] = Box::leak(vec![top, "Plug-ins", registry::intern(&name)].into_boxed_slice());
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id: registry::intern(&id),
label: registry::intern(&label),
menu,
shortcut: None,
params: registry::intern(&params),
enabled: has_doc,
run: unrouted,
journal: true,
}));
made.push(spec);
spec
}
/// A plug-in command's `run`: plug-in commands run through [`super::run_spec`], which passes
/// [`run_command`] the command id.
fn unrouted(_: &mut Session, _: &Value) -> Result<Value> {
Err(EngineError::Internal { cmd: "plugin".into(), msg: "a plug-in command runs through Session::execute".into() })
}
/// Runs plug-in command `id` (`plugin.<plug-in id>.<command id>`): a filter run of the plug-in on
/// `ids` (else the selection) with the command's own parameters, checked before it runs, and
/// `_context.command`, under the plug-in's permissions and budget, applied as one undo step named
/// after the command → `{plugin, command, ids}`. A run that fails (a trap, an exhausted budget, an
/// output it may not make) leaves the document as it was, and the error names the plug-in and the
/// command.
pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
// An output handed in is for `plugin.run` alone.
s.plugin_output = None;
let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
let failed = {
let (name, label) = (permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME), command.shown_label());
move |e: EngineError| match e {
EngineError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(format!("“{name}” › {label}: {e}")),
}
};
let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?;
let output = r.run().map_err(&failed)?;
r.finish(s, &output).map_err(failed)
}
/// Admits the effect records a filter returned for object `n` (written, its effects still its own)
/// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
/// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own
/// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass,
/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the
/// host read one.
fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
use vectorcraft_render::effects;
const C: &str = "plugin.run";
let mut out = Vec::with_capacity(fx.len());
for (j, e) in fx.iter().enumerate() {
if n.appearance.effects.contains(e) {
out.push(e.clone());
continue;
}
let refuse = |why: String| format!("[{j}] `{}`: {why}", e.id);
let params = if e.params.is_null() { json!({}) } else { e.params.clone() };
match effect::installed(&e.id) {
Some(plugin) => {
plugin.manifest().resolve_params(&params).map_err(|why| refuse(why.to_string()))?;
}
None => super::params::check_effect_params(C, &e.id, &params).map_err(|why| refuse(why.to_string()))?,
}
if super::effectcmd::texture_load_effect(&e.id)
&& let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE)
&& t.as_str().is_none_or(|t| effects::pixel::texture(t).is_none())
{
return Err(refuse("`textureImage` must be a texture as effect.list gives it".into()));
}
let mut admitted = effects::new_effect(&e.id, &params).ok_or_else(|| refuse("no such effect".into()))?;
if effects::is_three_d(&e.id) {
effects::validate_three_d(n, &e.id, &admitted.params).map_err(refuse)?;
}
admitted.visible = e.visible;
out.push(admitted);
}
let items = n.appearance.items.iter().flat_map(|i| i.effects());
if out.iter().chain(items).filter(|e| effects::is_three_d(&e.id)).count() > 1 {
return Err(super::effectcmd::ONE_3D.into());
}
Ok(out)
}
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
/// The `.wasm` extension plug-in files have (File › Open installs them).
pub const EXTS: &[&str] = &["wasm"];
/// The plug-in module command `c`'s params give, loaded and checked, and its bytes: `path` (a
/// `.wasm` file) or `dataBase64` (the module, with `name` shown as its source).
fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec<u8>)> {
match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
(_, Some(data)) => {
if data.len() > MAX_DATA_CHARS {
return Err(bad(c, "`dataBase64` is larger than the module limit"));
}
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(c, "`dataBase64` is not valid base64"))?;
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
let plugin = match str_param(p, "name") {
Some(name) => plugin.with_source(name),
None => plugin,
};
Ok((plugin, bytes))
}
(Some(path), None) => load_path(path),
(None, None) => Err(bad(c, "give `path` (a .wasm file) or `dataBase64` (the module)")),
}
}
/// The permissions `grant` lists in command `c`'s params: `None` when it is left out (or null).
/// Anything but a list of permission names is an error.
fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
let Some(v) = p.get("grant").filter(|v| !v.is_null()) else {
return Ok(None);
};
let names = v.as_array().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?;
let mut out = Vec::with_capacity(names.len());
for n in names {
let name = n.as_str().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?;
let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD);
out.push(Permission::parse(name).ok_or_else(|| bad(c, format!("`grant` names an unknown permission `{shown}`")))?);
}
Ok(Some(out))
}
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id
/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant
/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?);
let grant = grant_param(C, p)?;
let (plugin, bytes) = load_given(C, p)?;
// `hash` pins the module the user was shown: one with other bytes is refused.
if let Some(h) = p.get("hash").filter(|v| !v.is_null())
&& h.as_str() != Some(plugin.hash())
{
return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed"));
}
if !replace && registry::get(plugin.id()).is_some() {
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
}
// A heavy plug-in is installed once the user agrees: whoever installs it asks them first.
if plugin.manifest().budget == Budget::Heavy && !allow_heavy {
let (minutes, times) = vectorcraft_plugins::heavy_terms();
return Err(bad(
C,
format!(
"“{}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees",
plugin.manifest().name
),
));
}
// A plug-in that lists permissions installs once the user grants them: whoever installs it
// asks them first, unless they already granted its id all of them.
let m = plugin.manifest();
let wanted = m.permissions();
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME);
let asked = permissions::names(&wanted).join(", ");
return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees")));
}
if m.asks_permissions() {
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
registry::grant(g.clone());
s.store_grant(&m.id, Some(g));
}
// Installed plug-ins stay installed after a restart (#2541).
s.store_plugin(plugin.id(), &bytes);
Ok(describe(&registry::install(plugin)))
}
/// `plugin.inspect`: what installing the module would install, without installing it, and whether
/// that needs the user to grant its permissions first (`needsApproval`).
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
load_given("plugin.inspect", p).map(|(plugin, _)| {
let mut v = describe(&plugin);
v["needsApproval"] = json!(!s.plugin_approved(&plugin));
v
})
}
/// The plug-in in the `.wasm` file `path`, and its module.
#[cfg(not(target_arch = "wasm32"))]
fn load_path(path: &str) -> Result<(Plugin, Vec<u8>)> {
crate::file_access::check_read(path).map_err(EngineError::Other)?;
let limits = vectorcraft_plugins::Limits::default();
let bytes = crate::store::read_capped(std::path::Path::new(path), limits.max_module_bytes as u64).map_err(EngineError::Other)?;
Ok((Plugin::load(&bytes, limits).map_err(err)?.with_source(path), bytes))
}
#[cfg(target_arch = "wasm32")]
fn load_path(_: &str) -> Result<(Plugin, Vec<u8>)> {
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
}
/// The plug-in folder last loaded, the plug-in store whose grants it was loaded with, and what
/// happened.
static FOLDER: Mutex<Option<(String, Option<std::path::PathBuf>, Value)>> = Mutex::new(None);
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and it, or the
/// session's plug-in store, has changed since the last load. Runs whenever preferences are applied
/// (startup, `prefs.set`). Native only.
pub(crate) fn sync_prefs(s: &Session) {
let folder = s.prefs.plugins_folder.trim();
if folder.is_empty() {
return;
}
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
if last.as_ref().is_some_and(|(f, store, _)| f == folder && *store == s.plugin_dir) {
return;
}
*last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder)));
}
/// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted
/// ([`Session::plugin_approved`], with the store's grants read once).
#[cfg(not(target_arch = "wasm32"))]
fn load_folder(s: &Session, folder: &str) -> Value {
if let Err(e) = crate::file_access::check_read(folder) {
return json!({"folder": folder, "error": e});
}
// A file in it may be a link to one elsewhere.
let allow = |f: &std::path::Path| match f.to_str() {
Some(f) => crate::file_access::check_read(f),
// A lossy name would check another file than the one loaded.
None if crate::file_access::current().is_none() => Ok(()),
None => Err(format!("automation path rejected: {} isn't valid text", f.display())),
};
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) {
Ok(r) => {
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
}
Err(e) => json!({"folder": folder, "error": e.to_string()}),
}
}
#[cfg(target_arch = "wasm32")]
fn load_folder(_: &Session, folder: &str) -> Value {
json!({"folder": folder, "error": "plug-in folders are not available on the web"})
}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.reload";
let folder = match p.get("path") {
Some(v) => v.as_str().map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "`path` must be a folder"))?.to_string(),
None => s.prefs.plugins_folder.trim().to_string(),
};
if folder.is_empty() {
return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder"));
}
let report = load_folder(s, &folder);
if let Some(e) = report.get("error").and_then(Value::as_str) {
return Err(EngineError::Other(e.to_string()));
}
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone()));
Ok(report)
}
fn list(_: &mut Session, _: &Value) -> Result<Value> {
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
}
fn info(_: &mut Session, p: &Value) -> Result<Value> {
let plugin = plugin_by_id("plugin.info", p)?;
let mut v = describe(&plugin);
v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null);
v["defaults"] = Value::Object(plugin.manifest().defaults());
v["lastError"] = json!(effect::last_error(plugin.id()));
Ok(v)
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
// Installed again, it asks again.
registry::revoke(id);
s.store_grant(id, None);
s.unstore_plugin(id);
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
}
/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove` and
/// `.reload`.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "plugin.list",
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
always,
list
),
cmd!(
query "plugin.info",
"Plug-in Info",
[],
None,
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}",
always,
info
),
cmd!(
"plugin.run",
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
has_doc,
run
),
cmd!(
query "plugin.install",
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
always,
install
),
cmd!(
query "plugin.inspect",
"Inspect Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)",
always,
inspect
),
cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove),
cmd!(
query "plugin.reload",
"Reload Plug-ins",
["Object", "Plug-ins"],
None,
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}",
always,
reload
),
]
}