mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
Scripts: the Startup and Shutdown folders run at launch and quit
Scripts in Scripts/Startup and Scripts/Shutdown in the settings folder were never run. The desktop and web apps now run the Startup scripts at launch, before a project opens, and the desktop app runs the Shutdown scripts as it quits, after the project is closed. Each folder runs in alphabetical order with case ignored, and files larger than 16 MiB are not run. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next one still runs. A Startup script that waits in a modal dialog goes on when the dialog closes, while the next scripts run. A Shutdown script's dialogs close as if canceled. With the settings folder on disk, each script runs as its file, so #include, $.fileName and $.evalFile resolve against the script's own folder. The new commands file.runStartupScripts and file.runShutdownScripts return the scripts that ran, those still waiting in a dialog, and those that failed. Like script.run, both are refused in confined automation sessions, in batches and in script steps, which now read one list of script commands (RUNS_SCRIPTS). The list also holds window.scriptPanel and window.restoreScriptPanels, which run ScriptUI panel scripts and were not refused on main either. The apps still call window.restoreScriptPanels at launch. plugins.md, scripting-parity.md, gaps.md, target-app-parity.md and agents.md describe the folders. Each script still runs in its own global scope, so a Startup script's variables are not available to later scripts.
This commit is contained in:
@@ -97,6 +97,8 @@ mod tests_include;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
#[cfg(test)]
|
||||
mod tests_startup;
|
||||
#[cfg(test)]
|
||||
mod tests_tasks;
|
||||
#[cfg(test)]
|
||||
mod tests_ui;
|
||||
|
||||
@@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue {
|
||||
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let id = arg_string(args, 0, ctx)?;
|
||||
let mut params = arg_json(args, 1, ctx)?;
|
||||
if id == "script.run" || id == "file.runScript" {
|
||||
if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
|
||||
return Err(throw("scripts can't run scripts"));
|
||||
}
|
||||
let keys = params.as_object_mut().and_then(|o| o.remove("__keys"));
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup`
|
||||
//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and
|
||||
//! as the app quits, after the project is closed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR};
|
||||
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
|
||||
use effectcraft_engine::scriptui::WindowKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn session(store: &Arc<MemoryConfig>) -> Session {
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s.config = Some(store.clone());
|
||||
s
|
||||
}
|
||||
|
||||
/// A script that appends `mark` to the `Startup Test` / `order` script setting.
|
||||
fn mark(mark: &str) -> String {
|
||||
format!(
|
||||
r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : "";
|
||||
app.settings.saveSetting("Startup Test", "order", k + "{mark}");"#
|
||||
)
|
||||
}
|
||||
|
||||
fn setting(s: &mut Session, key: &str) -> serde_json::Value {
|
||||
s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap();
|
||||
// Not scripts, or not in the Startup folder: not run.
|
||||
store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap();
|
||||
store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap();
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}");
|
||||
let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(failed, ["C.js"]);
|
||||
assert_eq!(setting(&mut s, "order"), "AbBd");
|
||||
// The failing script is reported in a toast.
|
||||
let events = s.drain_events();
|
||||
assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}");
|
||||
// The Startup folder's scripts are not offered in File ▸ Scripts.
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_scripts_run_after_the_project_is_closed() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#;
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap();
|
||||
let mut s = session(&store);
|
||||
s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap();
|
||||
assert!(!s.project.items.is_empty());
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "items"), "0");
|
||||
assert!(s.project.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "order"), "B");
|
||||
assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
assert_eq!(setting(&mut s, "order"), "BA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_dialogs_close_as_if_canceled() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let code = r#"var a = new Window("dialog", "One").show();
|
||||
var b = new Window("dialog", "Two").show();
|
||||
app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#;
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap();
|
||||
// An error after the dialog closes is a failure.
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}");
|
||||
assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}");
|
||||
assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}");
|
||||
assert_eq!(setting(&mut s, "shown"), "2,2");
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_closes_only_its_own_scripts_dialogs() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap();
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap();
|
||||
let mut s = session(&store);
|
||||
assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"]));
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []}));
|
||||
// The Startup script's dialog is still open, and its script has not gone on.
|
||||
assert_eq!(setting(&mut s, "order"), "Q");
|
||||
let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Startup"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_scripts_are_not_run() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x"));
|
||||
store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r["ran"], json!([]));
|
||||
assert_eq!(r["failed"][0]["name"], "Big.jsx");
|
||||
let events = s.drain_events();
|
||||
assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}");
|
||||
assert_eq!(setting(&mut s, "order"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() {
|
||||
use effectcraft_engine::config::DirConfig;
|
||||
let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for (file, code) in [
|
||||
(format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")),
|
||||
(format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))),
|
||||
(format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))),
|
||||
] {
|
||||
let path = root.join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, code).unwrap();
|
||||
}
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(DirConfig::new(&root)));
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "order"), "im");
|
||||
// A Shutdown script run as its file still has its dialog closed.
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "order"), "imq");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A session with one comp, for checking that a refused command leaves the project open.
|
||||
fn session_with_comp(store: &Arc<MemoryConfig>) -> Session {
|
||||
let mut s = session(store);
|
||||
s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap();
|
||||
assert!(!s.project.items.is_empty());
|
||||
s
|
||||
}
|
||||
|
||||
const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"];
|
||||
|
||||
#[test]
|
||||
fn scripts_cannot_run_the_startup_or_shutdown_folders() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
for id in FOLDER_COMMANDS {
|
||||
let mut s = session_with_comp(&store);
|
||||
let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap();
|
||||
assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}");
|
||||
assert!(!s.project.items.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_scripts_cannot_run_the_startup_or_shutdown_folders() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
for id in FOLDER_COMMANDS {
|
||||
let mut s = session_with_comp(&store);
|
||||
let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("scripts can't run scripts"), "{id}: {e}");
|
||||
assert!(!s.project.items.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches_cannot_run_the_startup_or_shutdown_folders() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
for id in FOLDER_COMMANDS {
|
||||
let mut s = session_with_comp(&store);
|
||||
let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't run inside a batch"), "{id}: {e}");
|
||||
assert!(!s.project.items.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it
|
||||
/// runs, remembered as open at the last quit.
|
||||
fn session_with_panel(store: &Arc<MemoryConfig>) -> Session {
|
||||
store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap();
|
||||
store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap();
|
||||
session(store)
|
||||
}
|
||||
|
||||
fn panel_open(s: &Session) -> bool {
|
||||
s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx")
|
||||
}
|
||||
|
||||
/// The commands that open ScriptUI panels, which run the panel's script.
|
||||
fn panel_commands() -> [(&'static str, serde_json::Value); 2] {
|
||||
[("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_cannot_open_script_panels() {
|
||||
for (id, params) in panel_commands() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session_with_panel(&store);
|
||||
// Opened by the user: a script's call would only bring it forward.
|
||||
s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap();
|
||||
let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap();
|
||||
assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}");
|
||||
assert_eq!(setting(&mut s, "order"), "p", "{id}");
|
||||
assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_scripts_and_batches_cannot_open_script_panels() {
|
||||
for (id, params) in panel_commands() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session_with_panel(&store);
|
||||
let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("scripts can't run scripts"), "{id}: {e}");
|
||||
let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't run inside a batch"), "{id}: {e}");
|
||||
assert!(!panel_open(&s), "{id}");
|
||||
assert_eq!(setting(&mut s, "order"), "", "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_still_reopens_script_panels_at_launch() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session_with_panel(&store);
|
||||
// As the desktop and web apps call it at launch.
|
||||
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []}));
|
||||
assert!(panel_open(&s));
|
||||
assert_eq!(setting(&mut s, "order"), "p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_missing_folders_run_nothing() {
|
||||
let none = json!({"ran": [], "waiting": [], "failed": []});
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none);
|
||||
// A session without a settings folder.
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none);
|
||||
}
|
||||
Reference in New Issue
Block a user