Files
a439b1bb19 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>
2026-11-05 11:35:54 +01:00

314 lines
17 KiB
Rust

//! Effect plug-ins in a full session: a Rust plug-in registered through the trait and the
//! example WebAssembly plug-in (examples/plugins/posterize-bands) loaded with
//! `effect.plugins.load`, both applied by id like built-ins and rendered.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParam, PluginParamKind, PluginParams, register_plugin};
use effectcraft_engine::time::Tick;
use serde_json::json;
struct Swap {
m: PluginManifest,
}
impl EffectPlugin for Swap {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64) -> Result<(), String> {
if params.b("on") {
for p in frame.pixels.iter_mut() {
p.swap(0, 2);
}
}
Ok(())
}
}
fn setup() -> (Session, u64) {
let mut s = effectcraft_host::session();
s.execute("comp.new", json!({"name": "P", "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, l)
}
fn px(s: &Session) -> [f32; 4] {
let cid = s.active_comp_id().unwrap();
s.render(cid, Tick::ZERO, Default::default()).get(20, 10)
}
#[test]
fn rust_plugins_apply_by_id() {
let m = PluginManifest {
api: 1,
id: "org.test.swap".into(),
name: "Swap Red Blue".into(),
category: "Channel".into(),
version: "1".into(),
author: String::new(),
description: String::new(),
params: vec![PluginParam { id: "on".into(), name: "On".into(), kind: PluginParamKind::Checkbox { default: true }, group: String::new() }],
};
register_plugin(Arc::new(Swap { m })).unwrap();
let (mut s, l) = setup();
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.swap"})).unwrap();
let p = px(&s);
assert!(p[0] < 0.01 && (p[1] - 0.5).abs() < 0.01 && p[2] > 0.99, "{p:?}");
// Its parameters are ordinary properties: set (and animate) them like any effect's.
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/on", "value": false})).unwrap();
let p = px(&s);
assert!(p[0] > 0.99 && p[2] < 0.01, "{p:?}");
let list = s.execute("effect.plugins.list", json!({})).unwrap();
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == "org.test.swap" && p["category"] == "Channel"));
assert_eq!(list["api"], 2);
assert!(s.execute("effect.list", json!({"filter": "swap red"})).unwrap().as_array().unwrap().len() == 1);
}
/// Build the example plug-in (skipped when the wasm32 target isn't installed).
fn build_example() -> Option<std::path::PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands");
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let assigned = std::env::var_os("CARGO_TARGET_DIR");
let target = assigned.as_ref().map(std::path::PathBuf::from).unwrap_or_else(|| workspace.join("target"));
let target = if target.is_absolute() { target } else { workspace.join(target) };
// The plug-in is part of this test's build. Inherit the assigned target and
// compiler guards rather than creating a second target at its root.
let mut command = std::process::Command::new(env!("CARGO"));
command.current_dir(&workspace).args(["build", "--release", "--target", "wasm32-unknown-unknown", "--manifest-path"]).arg(root.join("Cargo.toml"));
if assigned.is_none() {
command.env("CARGO_TARGET_DIR", &target);
}
let st = command.status().ok()?;
if !st.success() {
eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)");
return None;
}
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 };
let (mut s, l) = setup();
let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap();
assert_eq!(r["loaded"][0]["id"], "org.effectcraft.example.posterize-bands", "{r}");
s.execute("effect.apply", json!({"layer": l, "effect": "Posterize Bands"})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/mix", "value": 0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/levels", "value": 2})).unwrap();
// Luminance of (1, .5, 0) is ~0.57: with 2 levels it bands to 1, scaling the colour by 1/y
// (8 bpc: red clips at 1).
let p = px(&s);
let y = 0.2126 + 0.7152 * 0.5;
assert!((p[1] - 0.5 / y as f32).abs() < 0.01 && p[0] > 0.99 && p[2] < 0.01, "{p:?}");
// Invert: band 0.
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/invert", "value": true})).unwrap();
assert_eq!(px(&s)[..3], [0.0, 0.0, 0.0]);
let list = s.execute("effect.plugins.list", json!({})).unwrap();
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm")));
}
/// A parameterless plug-in module (WebAssembly text): `render` is `ec_render`'s body.
pub(super) fn wat_plugin(id: &str, api: i32, render: &str) -> String {
let manifest = format!(r#"{{"api":1,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
let len = manifest.len();
let escaped = manifest.replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const {api})
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param i32) (result i32) i32.const 1024)
(func (export "ec_render") (param i32 i32 i32 i32 i32 f64 f64) (result i32) {render}))"#
)
}
#[test]
fn plugin_loading_reports_bad_modules_and_survives_failing_renders() {
let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let files = [
("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()),
("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()),
("c-v2.wasm", wat_plugin("org.test.host.v2", 2, "i32.const 0").into_bytes()),
("d-empty.wasm", vec![]),
("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()),
("notes.txt", b"not a plug-in".to_vec()),
];
for (name, bytes) in &files {
std::fs::write(dir.join(name), bytes).unwrap();
}
let (mut s, l) = setup();
let r = s.execute("effect.plugins.load", json!({"folder": dir.to_string_lossy()})).unwrap();
let ids: Vec<&str> = r["loaded"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect();
assert_eq!(ids, ["org.test.host.good", "org.test.host.trap"], "{r}");
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect();
assert_eq!(errs.len(), 3, "{r}");
assert!(errs.iter().all(|p| !p.ends_with(".txt")));
assert!(r["errors"].to_string().contains("API 2"), "{r}");
// A single file: errors come back as command errors; loading twice is refused.
let good = dir.join("a-good.wasm").to_string_lossy().to_string();
assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered"));
assert!(s.execute("effect.plugins.load", json!({"path": dir.join("missing.wasm").to_string_lossy()})).is_err());
assert!(s.execute("effect.plugins.load", json!({"folder": dir.join("missing").to_string_lossy()})).is_err());
assert!(s.execute("effect.plugins.load", json!({})).is_err());
// A plug-in that traps on every frame leaves the layer as it was.
let before = px(&s);
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.trap"})).unwrap();
assert_eq!(px(&s), before);
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.good"})).unwrap();
assert_eq!(px(&s), before);
let _ = std::fs::remove_dir_all(&dir);
}