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:
dotCooCoo
2026-10-24 02:44:05 -07:00
parent eb5108786b
commit efe7581f93
15 changed files with 421 additions and 16 deletions
+2
View File
@@ -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;
+1 -1
View File
@@ -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"));
+269
View File
@@ -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);
}