Effect ▸ Install Effect Plug-in… and plug-in bundles (wasm + ScriptUI panel in one go) (#2749)

* plugins: install effect plug-in bundles (wasm + ScriptUI panels) in one go

* plugins: address review - keep listing errors, refuse replacing an installed plug-in

* plugins: report unreadable bundle entries when their metadata cannot be read

* plugins: surface partial install errors in the menu bar, cover the install flow with tests

* plugins: surface partial install errors for async dialog answers too

* plugins: show result errors as compact status text

* plugins: harden installs - confined automation, no panel overwrite, size cap, cleanup errors

* plugins: bound install reads from one handle, detect unreadable panels, clarify install docs

* plugins: drop an unneeded mut in the bounded read

* plugins: saturate the bounded read limit

* plugins: install into the session's Plug-ins folder, refuse hidden single installs and case-variant panels

- Install .wasm files into Session::plugins_dir() (the folder the Plug-in Library
  from #2524 uses), not a path rebuilt from the settings folder; the host tests
  now point both at their temp folder so they never touch the real settings.
- A single install of a hidden file (".x.wasm") was skipped and reported as a
  success that installed nothing: it is now an error.
- Installed names must be bare file names (and not Windows device names on
  Windows), as file.installScript requires, so every installed panel is listed
  and can be uninstalled.
- A panel whose name differs from an installed one only in case replaced it on
  Windows and macOS: compare ignoring case and check the file on disk too.
- The loaded plug-in's source is the installed copy, not the file it came from.
- Document the `bundle` menu parameter, so checked execution accepts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* plugins: drop needless to_vec in the bundle install test (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* plugins: keep the loader test's byte vectors, drop to_vec in the install test (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: STAR Web Studio <hello.starwebstudio@gmail.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-05 11:35:54 +01:00
1 parent 611a282bea
commit a439b1bb19
13 files changed
+509 -10

No files matched your search

Generated
+1
View File
@@ -1675,6 +1675,7 @@ dependencies = [
"log",
"rayon",
"serde_json",
"tempfile",
]
[[package]]
+1 -1
View File
@@ -271,7 +271,7 @@ fn main() -> eframe::Result {
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = effectcraft_host::config_dir() {
// WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
let plugins = dir.join("Plug-ins");
let plugins = dir.join(effectcraft_engine::commands::PLUGINS_DIR);
if plugins.is_dir() {
let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
// Packages installed from the Plug-in Library (<config>/Plug-ins/Library).
+25
View File
@@ -46,6 +46,10 @@ const REFUSED: &[&str] = &[
"file.installScript",
"file.installScriptUIPanel",
"file.uninstallScript",
// Copies a plug-in (and a bundle's ScriptUI panels) from a user-chosen path into the folders
// the app loads code from: an agent must not plant code that runs on every start, and its
// sources are checked no better than `file.installScript`'s.
"effect.plugins.install",
// A ScriptUI panel's script runs when the panel opens, when an event reaches one of its
// controls, and when one of its `app.scheduleTask` tasks comes due.
"window.scriptPanel",
@@ -628,4 +632,25 @@ mod tests {
assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err());
let _ = std::fs::remove_dir_all(&base);
}
/// Installing a plug-in plants code the app runs at every start, so confinement refuses it
/// even when the file is inside a read root (like `file.installScript`).
#[test]
fn installing_plugins_is_refused_while_confined() {
let base = std::env::temp_dir().join(format!("ec-confine-plugins-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let read = base.join("in");
std::fs::create_dir_all(&read).unwrap();
let c = Confinement::new(std::slice::from_ref(&read), &[]).unwrap();
let inside = read.join("a.wasm").to_string_lossy().into_owned();
for params in [
serde_json::json!({"path": inside}),
serde_json::json!({"folder": read.to_string_lossy()}),
serde_json::json!({"path": "a.wasm"}),
serde_json::json!({"bundle": true}),
] {
assert!(c.check_command("effect.plugins.install", &params).is_err(), "{params}");
}
let _ = std::fs::remove_dir_all(&base);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ pub mod content_fill;
mod create;
mod edit;
mod effect;
pub use effect::{CryptoCoverage, CryptoProbeJob};
pub use effect::{CryptoCoverage, CryptoProbeJob, PLUGINS_DIR};
pub(crate) use effect::{CryptoProbeSlot, crypto_coverage, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
pub mod essential;
pub mod expr_tools;
+156
View File
@@ -71,6 +71,153 @@ fn plugins_load(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"loaded": loaded, "errors": errors}))
}
/// The settings-folder name the desktop app scans for WebAssembly effect plug-ins on start.
/// The scan uses this constant ([`apps/effectcraft/src/main.rs`]), so the two cannot drift.
pub const PLUGINS_DIR: &str = "Plug-ins";
/// The most one installed file may be: a plug-in is a compiled effect, a panel is script text.
/// Above this the install refuses rather than reading an unbounded file into memory and copying
/// it into a folder the app loads at every start.
const MAX_INSTALL_BYTES: u64 = 64 << 20;
/// `effect.plugins.install`: install WebAssembly effect plug-ins so they load on every start,
/// not only in this session (Load Effect Plug-in keeps the file where it is and just loads it).
/// `path` installs one `.wasm`; `folder` installs a bundle in one go: every `.wasm` at its top
/// level is copied into the Plug-ins folder and every `.jsx` becomes a ScriptUI panel. What
/// installs also loads in this session at once, so the effect menu has it immediately. A `.wasm`
/// that fails to load is not copied: a broken plug-in must not fail every start.
fn plugins_install(s: &mut Session, p: &Value) -> Result<Value> {
let c = "effect.plugins.install";
let loader = s.plugin_loader.ok_or_else(|| EngineError::Other("WebAssembly effect plug-ins are not available in this build".into()))?;
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install plug-ins into".into()))?;
let root = cfg.dir().ok_or_else(|| EngineError::Other("this session has no settings folder to install plug-ins into".into()))?;
// The folder the Plug-in Library installs next to (`Session::plugins_dir`: the host's, else
// `Plug-ins` next to the settings), so both kinds of install live in one place.
let plug_dir = s.plugins_dir().unwrap_or_else(|| root.join(PLUGINS_DIR));
let single = str_p(p, "path").is_some();
let mut listing_errors: Vec<Value> = Vec::new();
let sources: Vec<std::path::PathBuf> = match (str_p(p, "path"), str_p(p, "folder")) {
(Some(path), _) => vec![std::path::PathBuf::from(path)],
(None, Some(dir)) => {
let read = std::fs::read_dir(dir).map_err(|e| bad(c, format!("cannot list {dir}: {e}")))?;
let mut v = Vec::new();
for entry in read {
match entry {
Ok(e) => match e.path().metadata() {
Ok(m) if m.is_file() => v.push(e.path()),
// A vanished or unreadable entry is a bundle problem: report it rather
// than installing a bundle that silently misses files. (`is_file()`
// alone would treat unreadable metadata as "not a file" and skip it.)
Err(e) => listing_errors.push(json!({"error": format!("cannot read the bundle folder: {e}")})),
Ok(_) => {}
},
Err(e) => listing_errors.push(json!({"error": format!("cannot read the bundle folder: {e}")})),
}
}
v.sort();
v
}
_ => return Err(bad(c, "give `path` (a .wasm plug-in) or `folder` (a plug-in bundle)")),
};
let mut errors = listing_errors;
let mut installed = Vec::new();
let mut loaded = Vec::new();
let mut panels = Vec::new();
for src in &sources {
let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
if name.is_empty() || name.starts_with('.') {
// A bundle's hidden files (`.DS_Store`, `._x.wasm` resource forks) are skipped; a
// single install must not report success for a file it skipped.
if single {
return Err(bad(c, format!("`{}` is not a plug-in file name (hidden files are not installed)", src.display())));
}
continue;
}
let is_wasm = src.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"));
let is_panel = src.extension().is_some_and(|x| x.eq_ignore_ascii_case("jsx"));
if single {
if !is_wasm {
return Err(bad(c, "an effect plug-in install is a `.wasm` file"));
}
} else if !is_wasm && !is_panel {
continue; // a bundle may carry readmes, manifests and source: skip them
}
let body: std::result::Result<(), String> = (|| {
// The name the installed file gets, so it must be one the Scripts menu lists and
// `file.uninstallScript` accepts (like `file.installScript`'s): one bare component,
// and on Windows not a device name or one Windows rewrites.
if !super::scripts::is_bare_name(&name) || (cfg!(windows) && super::scripts::windows_unsafe_name(&name)) {
return Err(format!("`{name}` is not a file name that can be installed"));
}
// Never silently replace an installed file: a name collision is either a re-install
// the user did not ask to overwrite or a bundle that would shadow a working plug-in,
// and the update path is explicit (remove the old one first).
if is_panel {
let key = format!("{}/{name}", super::scripts::PANELS_DIR);
// Listed, not read: an existing panel whose text cannot be read is still a panel
// and must not be replaced (reading it would report it as absent). Compared
// ignoring case and checked on disk too: on Windows and macOS `my panel.jsx` is
// `My Panel.jsx`, and writing it would replace that panel.
let lower = name.to_lowercase();
if cfg.list(super::scripts::PANELS_DIR).iter().any(|n| n.to_lowercase() == lower) || cfg.files().is_file(&root.join(&key)) {
return Err(format!("{name} is already installed as a ScriptUI panel; remove it from the Scripts/ScriptUI Panels folder first to update"));
}
let bytes = s.services.read_file_bounded(&src.to_string_lossy(), MAX_INSTALL_BYTES).map_err(|e| format!("cannot read it: {e}"))?;
let code = String::from_utf8(bytes).map_err(|_| "panels are UTF-8 text (.jsx)".to_string())?;
cfg.write(&key, &code).map_err(|e| format!("cannot install panel: {e}"))?;
panels.push(json!(name));
return Ok(());
}
let target = plug_dir.join(&name);
if cfg.files().is_file(&target) {
return Err(format!("{name} is already installed; remove it from the Plug-ins folder first to update"));
}
let bytes = s.services.read_file_bounded(&src.to_string_lossy(), MAX_INSTALL_BYTES).map_err(|e| format!("cannot read it: {e}"))?;
cfg.files().write(&target, &bytes).map_err(|e| format!("cannot write into the Plug-ins folder: {e}"))?;
// Load in-session now: a plug-in that cannot load is rolled back, so a broken
// plug-in never makes every start fail.
// Its source is the installed copy (what `effect.plugins.list` shows and the next
// start loads), not the file it was installed from.
match loader(&bytes, &target.to_string_lossy()) {
Ok(v) => {
loaded.push(v);
installed.push(json!(name));
Ok(())
}
Err(e) => match cfg.files().remove(&target) {
Ok(()) => Err(format!("it does not load, nothing was installed: {e}")),
// Never say "nothing was installed" when the invalid copy is still there:
// every start would report it and a re-install would say "already installed".
Err(re) => Err(format!(
"it does not load and its copy could not be removed from the Plug-ins folder ({re}): delete `{name}` there, or every start reports it; {e}"
)),
},
}
})();
match body {
Ok(()) => {}
Err(e) => {
if single {
return Err(bad(c, format!("{name}: {e}")));
}
errors.push(json!({"file": name, "error": e}));
}
}
}
if !panels.is_empty() {
s.scripts_changed();
}
if !installed.is_empty() || !panels.is_empty() {
let mut toast = format!("Installed {} effect plug-in(s)", installed.len());
if !panels.is_empty() {
toast.push_str(&format!(" and {} ScriptUI panel(s)", panels.len()));
}
toast.push_str(": they load on every start");
s.toast(toast);
}
Ok(json!({"installed": installed, "panels": panels, "loaded": loaded, "errors": errors}))
}
/// External effect helpers need a host that runs them.
fn has_helpers(s: &Session) -> std::result::Result<(), String> {
if s.helpers.is_some() { Ok(()) } else { Err("effect helpers are not available in this build".into()) }
@@ -1205,6 +1352,15 @@ pub fn specs() -> Vec<CommandSpec> {
super::always,
plugins_load
),
cmd!(
"effect.plugins.install",
"Install Effect Plug-in...",
[],
None,
"{path (.wasm plug-in: copied into the Plug-ins folder and loaded on every start) | folder (a plug-in bundle: every .wasm goes into the Plug-ins folder and every .jsx becomes a ScriptUI panel), bundle? (the menu: ask for a folder)}",
super::always,
plugins_install
),
crate::query!(
"effect.helpers.list",
"List Effect Helpers",
+2
View File
@@ -1015,6 +1015,8 @@ Effect
---
Manage Effects... | effect.manage
Load Effect Plug-in... | effect.plugins.load
Install Effect Plug-in... | effect.plugins.install
Install Effect Plug-in Bundle (Folder)... | effect.plugins.install {"bundle": true}
Load Plug-ins Through Helper... | effect.helpers.load
Scan for Effect Helpers | effect.helpers.scan
Plug-in Library... | prefs.open {"page":"library"}
+44
View File
@@ -3,6 +3,17 @@ use super::config;
/// Host services (file access) injected by the frontend.
pub trait Services: Send + Sync {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
/// [`Services::read_file`] for a copy that must not load an unbounded file: more than `max`
/// bytes is an error. The native one opens the file once, reads its size from that handle and
/// reads through a [`std::io::Read::take`] limit, so a file grown or swapped after the check
/// still cannot read past `max`.
fn read_file_bounded(&self, path: &str, max: u64) -> std::io::Result<Vec<u8>> {
let bytes = self.read_file(path)?;
if bytes.len() as u64 > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{path} is larger than the {max}-byte limit")));
}
Ok(bytes)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>;
/// Whether a file exists (the background footage check after open).
fn exists(&self, path: &str) -> bool {
@@ -65,6 +76,26 @@ impl Services for FsServices {
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
fn read_file_bounded(&self, path: &str, max: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file")));
}
if meta.len() > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{path} is larger than the {max}-byte limit")));
}
let mut bytes = Vec::new();
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
// The size above came from this handle, and the read is capped too: a file that grows
// after the check still cannot read past `max`. Saturating, so `u64::MAX` cannot overflow.
file.take(max.saturating_add(1)).read_to_end(&mut bytes)?;
if bytes.len() as u64 > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{path} is larger than the {max}-byte limit")));
}
Ok(bytes)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
config::atomic_write(std::path::Path::new(path), data)
}
@@ -99,4 +130,17 @@ mod tests {
assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data");
let _ = std::fs::remove_file(f);
}
/// The limit is inclusive and cannot overflow: `u64::MAX` reads the file, one byte too many
/// is an error, and nothing past it is read.
#[test]
fn fs_services_bounded_read_stops_at_the_limit() {
let f = std::env::temp_dir().join(format!("ec-services-bounded-{}.txt", std::process::id()));
std::fs::write(&f, b"data").unwrap();
let p = f.to_string_lossy().to_string();
assert_eq!(FsServices.read_file_bounded(&p, u64::MAX).unwrap(), b"data");
assert_eq!(FsServices.read_file_bounded(&p, 4).unwrap(), b"data");
assert!(FsServices.read_file_bounded(&p, 3).is_err());
let _ = std::fs::remove_file(f);
}
}
+1
View File
@@ -28,6 +28,7 @@ effectcraft-helper-host = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
tempfile = { workspace = true }
effectcraft-psd = { workspace = true }
effectcraft-svg = { workspace = true }
effectcraft-pdf = { workspace = true }
+148
View File
@@ -88,6 +88,154 @@ fn build_example() -> Option<std::path::PathBuf> {
Some(target.join("wasm32-unknown-unknown/release/posterize_bands.wasm"))
}
/// A host session whose settings and Plug-ins folder are both in `config`: installs must never
/// reach the real settings folder the host session points its Plug-ins folder at.
fn install_session(config: &std::path::Path) -> Session {
let mut s = effectcraft_host::session();
s.config = Some(Arc::new(effectcraft_engine::config::DirConfig::new(config)));
s.plugins_dir = Some(config.join("Plug-ins"));
s
}
#[test]
fn plugin_bundle_install_copies_wasm_and_installs_panels() {
let config = tempfile::TempDir::new().unwrap();
let config = config.path();
let plug_dir = config.join("Plug-ins");
let bundle = tempfile::TempDir::new().unwrap();
let bundle = bundle.path();
std::fs::write(bundle.join("a-good.wasm"), wat_plugin("org.test.host.install", 1, "i32.const 0")).unwrap();
std::fs::write(bundle.join("My Panel.jsx"), "1".as_bytes()).unwrap();
std::fs::write(bundle.join("notes.txt"), b"not a plug-in").unwrap();
let mut s = install_session(config);
let r = s.execute("effect.plugins.install", json!({"folder": bundle.to_string_lossy()})).unwrap();
assert_eq!(r["installed"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}");
assert_eq!(r["panels"].as_array().unwrap()[0], json!("My Panel.jsx"), "{r}");
// The plug-in is on disk where the start-up scan looks, and the panel is listable like an
// installed one.
assert!(plug_dir.join("a-good.wasm").is_file(), "{r}");
assert!(s.config.as_ref().unwrap().read("Scripts/ScriptUI Panels/My Panel.jsx").as_deref() == Some("1"), "{r}");
// The effect is usable in this session already.
s.execute("comp.new", json!({"name": "I", "width": 40, "height": 20, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": [1.0, 0.5, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": l, "effect": "Host org.test.host.install"})).unwrap();
// A load failure rolls the copy back: nothing left behind to fail every start.
let bad = tempfile::TempDir::new().unwrap();
let bad = bad.path();
std::fs::write(bad.join("b-garbage.wasm"), b"\0asm\x01\0\0\0garbage").unwrap();
let r2 = s.execute("effect.plugins.install", json!({"folder": bad.to_string_lossy()})).unwrap();
assert_eq!(r2["errors"].as_array().unwrap().len(), 1, "{r2}");
assert!(r2["installed"].as_array().unwrap().is_empty(), "{r2}");
assert!(!plug_dir.join("b-garbage.wasm").exists(), "{r2}");
// A single-file install: non-wasm paths are refused, and good files land on disk.
assert!(s.execute("effect.plugins.install", json!({"path": bundle.join("My Panel.jsx").to_string_lossy()})).is_err());
// A single-file success: the file lands in the Plug-ins folder and is reported installed.
let fresh = bundle.join("fresh.wasm");
std::fs::write(&fresh, wat_plugin("org.test.host.fresh", 1, "i32.const 0")).unwrap();
let r3 = s.execute("effect.plugins.install", json!({"path": fresh.to_string_lossy()})).unwrap();
assert_eq!(r3["installed"].as_array().unwrap()[0], json!("fresh.wasm"), "{r3}");
assert!(plug_dir.join("fresh.wasm").is_file(), "{r3}");
// Again from `config`: an already-registered (in-session) id fails as a duplicate.
let again = config.join("keep.wasm");
std::fs::copy(bundle.join("a-good.wasm"), &again).unwrap();
assert!(s.execute("effect.plugins.install", json!({"path": again.to_string_lossy()})).is_err());
}
#[test]
fn plugin_install_never_replaces_an_installed_plugin() {
let config = tempfile::TempDir::new().unwrap();
let config = config.path();
let plug_dir = config.join("Plug-ins");
let src = tempfile::TempDir::new().unwrap();
let src = src.path();
std::fs::write(src.join("a-good.wasm"), wat_plugin("org.test.host.replace", 1, "i32.const 0")).unwrap();
let mut s = install_session(config);
s.execute("effect.plugins.install", json!({"path": src.join("a-good.wasm").to_string_lossy()})).unwrap();
let installed = std::fs::read(plug_dir.join("a-good.wasm")).unwrap();
// Same file name, a different plug-in: installing over the existing one must be refused,
// not quietly swap it — and the first plug-in's bytes on disk stay exactly what they were.
let attack = tempfile::TempDir::new().unwrap();
let attack = attack.path();
std::fs::write(attack.join("a-good.wasm"), wat_plugin("org.test.host.hack", 1, "i32.const 0")).unwrap();
let r = s.execute("effect.plugins.install", json!({"folder": attack.to_string_lossy()})).unwrap();
assert!(r["installed"].as_array().unwrap().is_empty(), "{r}");
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["error"].as_str().unwrap().to_string()).collect();
assert!(errs.iter().any(|e| e.contains("already installed")), "{r}");
assert_eq!(std::fs::read(plug_dir.join("a-good.wasm")).unwrap(), installed);
// The same through a single-file install: a command error, disk untouched.
assert!(s.execute("effect.plugins.install", json!({"path": attack.join("a-good.wasm").to_string_lossy()})).is_err());
assert_eq!(std::fs::read(plug_dir.join("a-good.wasm")).unwrap(), installed);
// A ScriptUI panel is code too: a bundle must not replace an installed one silently either.
let p1 = tempfile::TempDir::new().unwrap();
std::fs::write(p1.path().join("My Panel.jsx"), "1").unwrap();
s.execute("effect.plugins.install", json!({"folder": p1.path().to_string_lossy()})).unwrap();
let p2 = tempfile::TempDir::new().unwrap();
std::fs::write(p2.path().join("My Panel.jsx"), "2").unwrap();
let r = s.execute("effect.plugins.install", json!({"folder": p2.path().to_string_lossy()})).unwrap();
assert!(r["panels"].as_array().unwrap().is_empty(), "{r}");
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["error"].as_str().unwrap().to_string()).collect();
assert!(errs.iter().any(|e| e.contains("already installed as a ScriptUI panel")), "{r}");
assert_eq!(s.config.as_ref().unwrap().read("Scripts/ScriptUI Panels/My Panel.jsx").as_deref(), Some("1"));
}
/// A single install of a hidden file is an error, not a success that installed nothing; a panel
/// whose name differs from an installed one only in case is refused (on Windows and macOS writing
/// it would replace that panel); an installed plug-in's source is its installed copy.
#[test]
fn plugin_install_refuses_hidden_files_and_case_variant_panels() {
let config = tempfile::TempDir::new().unwrap();
let config = config.path();
let src = tempfile::TempDir::new().unwrap();
let src = src.path();
let mut s = install_session(config);
let hidden = src.join(".hidden.wasm");
std::fs::write(&hidden, wat_plugin("org.test.host.hidden", 1, "i32.const 0")).unwrap();
assert!(s.execute("effect.plugins.install", json!({"path": hidden.to_string_lossy()})).is_err());
assert!(!config.join("Plug-ins").join(".hidden.wasm").exists());
let p1 = tempfile::TempDir::new().unwrap();
std::fs::write(p1.path().join("Case Panel.jsx"), "1").unwrap();
s.execute("effect.plugins.install", json!({"folder": p1.path().to_string_lossy()})).unwrap();
let p2 = tempfile::TempDir::new().unwrap();
std::fs::write(p2.path().join("case panel.jsx"), "2").unwrap();
let r = s.execute("effect.plugins.install", json!({"folder": p2.path().to_string_lossy()})).unwrap();
assert!(r["panels"].as_array().unwrap().is_empty(), "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(s.config.as_ref().unwrap().read("Scripts/ScriptUI Panels/Case Panel.jsx").as_deref(), Some("1"));
let good = src.join("sourced.wasm");
std::fs::write(&good, wat_plugin("org.test.host.sourced", 1, "i32.const 0")).unwrap();
s.execute("effect.plugins.install", json!({"path": good.to_string_lossy()})).unwrap();
let list = s.execute("effect.plugins.list", json!({})).unwrap();
let entry = list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == "org.test.host.sourced").cloned().unwrap();
assert_eq!(entry["source"], json!(config.join("Plug-ins").join("sourced.wasm").to_string_lossy()), "{entry}");
}
/// A huge file is refused before it is read into memory and copied into the folder the app loads
/// at every start.
#[test]
fn plugin_install_refuses_an_oversized_file() {
let config = tempfile::TempDir::new().unwrap();
let src = tempfile::TempDir::new().unwrap();
let big = src.path().join("big.wasm");
// Sparse on every platform we build for: no 64 MiB written, just the length.
std::fs::File::create(&big).unwrap().set_len((64 << 20) + 1).unwrap();
let mut s = install_session(config.path());
let Err(e) = s.execute("effect.plugins.install", json!({"path": big.to_string_lossy()})) else { panic!("an oversized plug-in was installed") };
assert!(e.to_string().contains("byte limit"), "{e}");
assert!(!config.path().join("Plug-ins/big.wasm").exists(), "{e}");
}
#[test]
fn example_wasm_plugin_loads_and_renders() {
let Some(wasm) = build_example() else { return };
+5 -4
View File
@@ -115,10 +115,11 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
egui::Popup::close_id(&ctx, top);
}
}
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params))
&& let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params })
{
app.ui.status = e;
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params)) {
match invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params }) {
Ok(result) => super::frontend::show_result_errors(app, &result),
Err(e) => app.ui.status = e,
}
}
// What the next frame starts from: the open title, and a title press still in progress.
let open = nav.open_index(&ctx);
+27 -2
View File
@@ -583,6 +583,8 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
"file.runScript" if params.get("name").is_none() && params.get("recent").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
"effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
"effect.plugins.install" if params.get("bundle").is_some() => ("folder", Ask::Folder),
"effect.plugins.install" if params.get("folder").is_none() && params.get("path").is_none() => ("path", Ask::Open(&["wasm"])),
"effect.helpers.load" => ("path", Ask::Folder),
"file.replaceFootage" => ("path", Ask::Import),
"file.relinkFootage" => ("folder", Ask::Folder),
@@ -680,11 +682,34 @@ fn picked_value(key: &str, paths: Vec<String>) -> Option<Value> {
/// command with the picked path (nothing when it was cancelled).
pub(crate) fn dialog_answered(app: &mut EffectcraftApp, answer: crate::DialogAnswer) {
let Some(v) = picked_value(answer.key, answer.paths) else { return };
if let Err(e) = run_with_picked(app, &answer.command, answer.key, &answer.params, v) {
app.ui.status = e;
match run_with_picked(app, &answer.command, answer.key, &answer.params, v) {
Ok(result) => show_result_errors(app, &result),
Err(e) => app.ui.status = e,
}
}
/// A successful command can still report per-item problems in an `errors` array (Effect ▸
/// Install Effect Plug-in Bundle does): show a short, readable report in the status line,
/// without turning the success into an error. The full structured array stays in the result
/// for scripts; the status line is single-line and short-lived, so it lists the first few
/// problems and counts the rest instead of printing raw JSON.
pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) {
let Some(errors) = result.get("errors").and_then(Value::as_array).filter(|e| !e.is_empty()) else { return };
let parts: Vec<String> = errors
.iter()
.map(|e| match (e.get("file").and_then(Value::as_str), e.get("error").and_then(Value::as_str)) {
(Some(file), Some(error)) => format!("{file}: {error}"),
(_, Some(error)) => error.to_string(),
_ => e.to_string(),
})
.collect();
let mut msg = parts.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
if parts.len() > 3 {
msg.push_str(&format!("; +{} more", parts.len() - 3));
}
app.ui.status = msg;
}
/// Run command `id` with the path picked for it in parameter `key`.
fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result<Value, String> {
let mut p = params.as_object().cloned().unwrap_or_default();
+48
View File
@@ -637,3 +637,51 @@ fn menu_items_cover_the_tree() {
assert_eq!(menus().last(), Some(&"Help"));
assert!(effectcraft_engine::menus::entries().len() > 500);
}
/// A successful command that reports per-item problems (`errors`) shows a compact, readable
/// report in the status line: `file: error; …`, first three and a count, never raw JSON.
#[test]
fn bundle_result_errors_show_compactly_in_the_status_line() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let result = json!({"installed": [], "errors": [
{"file": "a.wasm", "error": "already installed"},
{"file": "b.wasm", "error": "does not load"},
{"file": "c.wasm", "error": "cannot read it"},
{"error": "cannot read the bundle folder"},
]});
super::frontend::show_result_errors(&mut app, &result);
assert_eq!(app.ui.status, "a.wasm: already installed; b.wasm: does not load; c.wasm: cannot read it; +1 more");
// A clean success leaves the status line alone.
super::frontend::show_result_errors(&mut app, &json!({"installed": ["x.wasm"], "errors": []}));
assert_eq!(app.ui.status, "a.wasm: already installed; b.wasm: does not load; c.wasm: cannot read it; +1 more");
}
/// Effect ▸ Install Effect Plug-in… picks its dialog by params: a bundle asks for a folder,
/// a plain install asks for a `.wasm`, and with both given inline nothing asks at all.
#[test]
fn effect_plugin_install_dialog_router() {
use std::sync::{Arc, Mutex};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
let pending = Arc::new(Mutex::new(Vec::new()));
let queue = pending.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog)));
// The bundle menu entry: a folder picker.
assert_eq!(invoke(&mut app, &ctx, "effect.plugins.install", json!({"bundle": true})).unwrap(), json!({"dialog": "effect.plugins.install"}));
assert_eq!(pending.lock().unwrap().pop().unwrap(), crate::FileDialog::Folder, "the bundle asks for a folder");
// The plain command: a `.wasm` file picker.
assert_eq!(invoke(&mut app, &ctx, "effect.plugins.install", json!({})).unwrap(), json!({"dialog": "effect.plugins.install"}));
let crate::FileDialog::Open { exts, many } = pending.lock().unwrap().pop().unwrap() else { panic!("not an open dialog") };
assert_eq!(exts, vec!["wasm".to_string()], "{exts:?}");
assert!(!many);
// `path` given inline overrides the bundle ask: the command runs directly, opening no dialog
// (this session has no plug-in loader, so it errors — but nothing was asked).
let r = invoke(&mut app, &ctx, "effect.plugins.install", json!({"bundle": true, "path": "x.wasm"}));
assert!(r.is_err(), "{r:?}");
assert!(pending.lock().unwrap().is_empty(), "no dialog for an inline path");
// A supplied `folder` is a bundle source too: asking for a file would let the install take
// the picked file and ignore the folder, so nothing is asked.
let r = invoke(&mut app, &ctx, "effect.plugins.install", json!({"folder": "some/where"}));
assert!(r.is_err(), "{r:?}");
assert!(pending.lock().unwrap().is_empty(), "no dialog for an inline folder");
}
+50 -2
View File
@@ -163,7 +163,7 @@ There are two ways to write one:
| | WebAssembly plug-in | Rust plug-in |
|---|---|---|
| What | a `.wasm` (or `.wat`) module | a type implementing `effectcraft_effects::plugin::EffectPlugin` |
| Loaded by | Effect ▸ Load Effect Plug-in…, `effect.plugins.load {path \| folder}`, or the `Plug-ins` folder next to the settings at start-up | `register_plugin(Arc::new(MyEffect))` in an app that links EffectCraft's crates |
| Loaded by | Effect ▸ Install Effect Plug-in… (copied into the `Plug-ins` folder next to the settings and loaded on every start-up), Effect ▸ Load Effect Plug-in… / `effect.plugins.load {path \| folder}` (loads in this session only), the `Plug-ins` folder next to the settings at start-up, or `effect.plugins.install {path \| folder}` | `register_plugin(Arc::new(MyEffect))` in an app that links EffectCraft's crates |
| Sandbox | yes: no imports (no files, clock or network), fuel-limited, at most 1 GiB of memory, deterministic floats | none (it is your code) |
| Platforms | desktop and CLI (the wasmi interpreter, crate feature `effectcraft-plugin/wasm`); not the web build | everywhere |
@@ -241,11 +241,59 @@ plain Rust (no dependencies):
```sh
cd examples/plugins/posterize-bands
cargo build --release --target wasm32-unknown-unknown
# then, in EffectCraft: Effect ▸ Load Effect Plug-in… → target/wasm32-unknown-unknown/release/posterize_bands.wasm
# then, in EffectCraft: Effect ▸ Install Effect Plug-in…
# → target/wasm32-unknown-unknown/release/posterize_bands.wasm (copied into Plug-ins, loads on start-up)
# or in-session only: Effect ▸ Load Effect Plug-in…
# or headless:
effectcraft-cli exec effect.plugins.load '{"path": "target/wasm32-unknown-unknown/release/posterize_bands.wasm"}'
```
**Bundles.** `effect.plugins.install {"folder": ...}` installs a whole plug-in folder in one
go: every `.wasm` at its top level is copied into the `Plug-ins` folder (and loads at once),
and every `.jsx` at its top level is installed as a ScriptUI panel — so a third-party plug-in
ships as one folder the user installs once. A `.wasm` that does not load is not copied (the
install rolls it back), and per-file problems in a folder install come back in `errors`
instead of failing the whole bundle.
**What an install does and does not do.** It is a security boundary: it copies code into a
folder the app runs at every start, so:
* Only `.wasm` (`path` / a bundle's `.wasm`) and `.jsx` (a bundle's panel) are installed; other
files in a bundle (readmes, manifests, sources) are skipped. Files are named by their own
entry name, so a bundle cannot carry a path, a `..` component or a name that escapes the
destination folder.
* Nothing is overwritten. An existing plug-in or panel with the same file name makes that file
fail with "already installed": delete it from `Plug-ins` / `Scripts/ScriptUI Panels` first to
update it (versioning the file name is the other option). A bundle is installed file by file,
so one collision does not undo the rest.
* Files over 64 MiB are refused before they are read, rather than copied into a startup folder.
* A `.wasm` is loaded once, in the same sandbox it runs in later (see below), so a module that
cannot load is reported instead of failing every start. That load runs the module's start-up
code and its metadata exports; `ec_render` is never called, and a panel's script is not run at
install time (it runs when the panel opens, as with any installed panel). The copy made before
the load is then removed again — best effort: if removing it fails, the command says so and
names the file, and you delete it from `Plug-ins` yourself.
* A `.wasm` is uninstalled by deleting it from `Plug-ins` (the next start no longer loads it):
there is no `effect.plugins.uninstall`. A panel installed here is an installed script, so
`file.uninstallScript {name}` removes it. Replacing either means removing it first.
* Hidden files (names starting with `.`, such as macOS `._x.wasm` copies) are skipped in a bundle
and refused as a single install. A panel name that differs from an installed panel's only in
letter case counts as the same name.
* Installed plug-ins share the `Plug-ins` folder with the [Plug-in Library](#plug-in-library)
(which keeps its packages in `Plug-ins/Library`). An effect id can be registered only once: a
plug-in whose id is already loaded, from either kind of install, is refused. At start-up the
files installed here load before the library's packages, so if both carry the same id (for
example after files were copied by hand), the file here wins and the library package reports
the duplicate.
**The sandbox.** A plug-in runs in the [wasmi](https://github.com/wasmi-labs/wasmi) interpreter
with no imports (no file, network or clock access), deterministic floats, a fuel budget per
frame and a 1 GiB memory cap, so a plug-in cannot escape the sandbox, hang the app or render
differently on another machine. A ScriptUI panel is different: it is a script with the app's
scripting object model, so it can read and write project data through commands; file and
network access for scripts follow Settings ▸ Scripting & Expressions, exactly as for a script
installed with File ▸ Scripts ▸ Install ScriptUI Panel….
Any language that compiles to WebAssembly works (C, Zig, AssemblyScript…) as long as the module
has no imports. `crates/plugin/src/tests.rs` has a plug-in written directly in WebAssembly text.