From a439b1bb19051c339e46c59166f39fac25812406 Mon Sep 17 00:00:00 2001 From: just_anshh Date: Thu, 5 Nov 2026 16:05:54 +0530 Subject: [PATCH] =?UTF-8?q?Effect=20=E2=96=B8=20Install=20Effect=20Plug-in?= =?UTF-8?q?=E2=80=A6=20and=20plug-in=20bundles=20(wasm=20+=20ScriptUI=20pa?= =?UTF-8?q?nel=20in=20one=20go)=20(#2749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * plugins: drop needless to_vec in the bundle install test (clippy) Co-Authored-By: Claude Opus 5.5 * plugins: keep the loader test's byte vectors, drop to_vec in the install test (clippy) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: STAR Web Studio Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/effectcraft/src/main.rs | 2 +- crates/automation/src/confine.rs | 25 +++++ crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/effect.rs | 156 +++++++++++++++++++++++++++ crates/engine/src/menus.rs | 2 + crates/engine/src/services.rs | 44 ++++++++ crates/host/Cargo.toml | 1 + crates/host/tests/it/plugins.rs | 148 +++++++++++++++++++++++++ crates/ui-egui/src/menus/bar.rs | 9 +- crates/ui-egui/src/menus/frontend.rs | 29 ++++- crates/ui-egui/src/menus/tests.rs | 48 +++++++++ docs/plugins.md | 52 ++++++++- 13 files changed, 509 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a62186b03..9ad887965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1675,6 +1675,7 @@ dependencies = [ "log", "rayon", "serde_json", + "tempfile", ] [[package]] diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 4d3f3386c..a70e03fcd 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -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 /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 (/Plug-ins/Library). diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 943fe9ed7..a41dd17bd 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -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", ¶ms).is_err(), "{params}"); + } + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index ee2624fd8..076be70ec 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 32cf7eb30..ba9fefaba 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -71,6 +71,153 @@ fn plugins_load(s: &mut Session, p: &Value) -> Result { 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 { + 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 = Vec::new(); + let sources: Vec = 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 { 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", diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 0308e0c16..ffff4cc04 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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"} diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index 2d425a4e6..5658df107 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -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>; + /// [`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> { + 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> { + 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); + } } diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 4efbcf7cc..70991b22e 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -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 } diff --git a/crates/host/tests/it/plugins.rs b/crates/host/tests/it/plugins.rs index d84548a50..7e4839e6d 100644 --- a/crates/host/tests/it/plugins.rs +++ b/crates/host/tests/it/plugins.rs @@ -88,6 +88,154 @@ fn build_example() -> Option { 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 = 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 = 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 }; diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index ec9ab9170..dc4478a47 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -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); diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 34176458b..f22b0f079 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -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) -> Option { /// 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 = 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::>().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 { let mut p = params.as_object().cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 0ed3fe7ad..0eaa957ac 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -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"); +} diff --git a/docs/plugins.md b/docs/plugins.md index 4f6031722..3fd8cf944 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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.