mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-05 18:18:49 +00:00
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:
13 files changed
+509
-10
No files matched your search
Generated
+1
@@ -1675,6 +1675,7 @@ dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user