Merge pull request #2279 from dotCooCoo/feat/startup-shutdown-scripts

Scripts: the Startup and Shutdown folders run at launch and quit
This commit is contained in:
BFlat
2026-10-30 07:05:12 +07:00
committed by GitHub
18 changed files with 498 additions and 24 deletions
+2
View File
@@ -272,6 +272,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
}
let mut session = session();
session.load_settings();
// Scripts in Scripts/Startup run before a project opens.
let _ = session.execute("file.runStartupScripts", json!({}));
let recovery = session.begin_recovery();
// `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
// `startup` says (default: the last visit's project, the demo on a first visit).
+5
View File
@@ -263,6 +263,9 @@ fn main() -> eframe::Result {
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
// Scripts in Scripts/Startup run before a project opens (a failing one is reported in
// a toast).
let _ = session.execute("file.runStartupScripts", json!({}));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
@@ -495,6 +498,8 @@ impl eframe::App for Desktop {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
// Scripts in Scripts/Shutdown run with the project closed.
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
+4 -4
View File
@@ -30,10 +30,9 @@ pub struct Confinement {
write: Vec<PathBuf>,
}
/// Commands that run code, act outside the project or open other applications.
/// Commands that run code, act outside the project or open other applications, besides the
/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]).
const REFUSED: &[&str] = &[
"script.run",
"file.runScript",
"file.installScript",
"file.installScriptUIPanel",
"file.uninstallScript",
@@ -167,7 +166,7 @@ impl Confinement {
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
if REFUSED.contains(&id) {
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
// Scripts can't grant themselves the access the user withheld
@@ -397,6 +396,7 @@ mod tests {
let classified = keys_of(READS, id).is_some()
|| keys_of(WRITES, id).is_some()
|| REFUSED.contains(&id)
|| effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id)
|| IMPLICIT_WRITES.contains(&id)
|| id.starts_with("renderQueue.")
|| id == "render.addOutputModule"
+12
View File
@@ -1412,6 +1412,18 @@ fn automation_roots_confine_file_access() {
// Scripts can read and write anything: refused.
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
for (id, params) in [
("file.runStartupScripts", json!({})),
("file.runShutdownScripts", json!({})),
// ScriptUI panels run their script when they open.
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
("window.restoreScriptPanels", json!({})),
] {
let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params}));
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]}));
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
}
// Writes to the files the agent named: none outside.
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
let _ = std::fs::remove_dir_all(&base);
+4 -3
View File
@@ -9,8 +9,9 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session};
/// Commands that can't run inside a batch (they replace the project or nest batches).
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
/// Commands that can't run inside a batch (they nest batches), besides the commands that run
/// scripts ([`super::scripts::RUNS_SCRIPTS`]).
const NOT_IN_BATCH: &[&str] = &["engine.batch"];
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| step.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
if NOT_IN_BATCH.contains(&id) {
if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
}
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
+9 -2
View File
@@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
};
if id == "file.runScript" || id == "script.run" {
if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
return Err(bad("file.runScript", "scripts can't run scripts"));
}
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
@@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool {
}
/// A script error becomes a toast and a command error.
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
@@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Va
report_js(s, name, out)
}
/// Run the code of the script file at `path` (its `#include`s and `$.fileName` use that path),
/// reported under its file name `name` (Startup and Shutdown scripts).
pub(crate) fn run_js_file(s: &mut Session, code: &str, path: &str, name: &str) -> Result<Value> {
let out = run_js(s, code, path, false)?;
report_js(s, name, out)
}
/// Run JavaScript through the session's scripting engine.
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
+112
View File
@@ -7,6 +7,9 @@
//! panels appear at the bottom of the Window menu and open as dockable panels
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by
//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft
//! quits, after the project is closed (`file.runShutdownScripts`).
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
@@ -23,6 +26,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
/// The settings-store folders of installed scripts.
pub const SCRIPTS_DIR: &str = "Scripts";
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
/// The settings-store folders whose scripts run at launch and as EffectCraft quits.
pub const STARTUP_DIR: &str = "Scripts/Startup";
pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown";
/// Commands that run script files or script code (a ScriptUI panel runs its script when it
/// opens). Scripts and batches can't run them, and confined automation sessions refuse them.
pub const RUNS_SCRIPTS: &[&str] =
&["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"];
/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and
/// `$.evalFile` allow.
const MAX_SCRIPT_BYTES: usize = 16 << 20;
/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled.
const MAX_SHUTDOWN_DIALOGS: usize = 64;
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
const PANELS_FOLDER: &str = "ScriptUI Panels";
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
@@ -337,6 +352,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"opened": opened, "failed": failed}))
}
/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case
/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is
/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a
/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs
/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as
/// if canceled (`show()` returns 2) and the script runs on.
fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value {
let mut names: Vec<String> = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect();
names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]);
for name in names {
let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) {
Some(Ok(Some(code))) => code,
read => {
let why = match read {
Some(Err(e)) => format!(": {e}"),
_ => String::new(),
};
let error = format!("{name}: cannot read the script{why}");
log::warn!("{dir}: {error}");
s.events.push(Event::Toast { message: error.clone(), error: true });
failed.push(json!({"name": name, "error": error}));
continue;
}
};
let is_waiting = |r: &Result<Value>| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true));
// In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName`
// and `$.evalFile` find the files beside it.
let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
// Windows open before this script runs belong to other scripts, perhaps of the same name.
let before: Vec<u32> = s.script_ui.windows.iter().map(|w| w.id).collect();
let hosts_before = s.script_ui.task_hosts.clone();
let mut r = super::file_more::run_js_file(s, &code, &path, &name);
if !quitting {
// The Startup script's context: the one that scheduled tasks or holds the windows it
// opened (a task scheduled after its dialog closes runs there too).
let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied();
let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host);
let hosts: Vec<u32> = tasks.chain(windows).collect();
for h in hosts {
if !s.script_ui.startup_hosts.contains(&h) {
s.script_ui.startup_hosts.push(h);
}
}
}
let mut closed = 0;
while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) {
let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break };
// report_js reports an error the script throws after its dialog closes, as it does for an
// error in the script's first run.
r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out));
closed += 1;
}
match r {
Ok(_) if is_waiting(&r) => waiting.push(name),
Ok(_) => ran.push(name),
Err(e) => {
// Also in the log: no frame draws a Shutdown script's toast.
log::warn!("{dir}: {e}");
failed.push(json!({"name": name, "error": e.to_string()}));
}
}
}
json!({"ran": ran, "waiting": waiting, "failed": failed})
}
/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at
/// launch, before a project opens).
fn run_startup(s: &mut Session, _: &Value) -> Result<Value> {
Ok(run_folder(s, STARTUP_DIR, false))
}
/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown`
/// (frontends call it as the app quits, after the unsaved-changes prompt).
fn run_shutdown(s: &mut Session, _: &Value) -> Result<Value> {
s.replace_project(effectcraft_project::Project::default(), None);
Ok(run_folder(s, SHUTDOWN_DIR, true))
}
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
@@ -414,6 +508,24 @@ pub fn specs() -> Vec<CommandSpec> {
always,
restore_panels
),
cmd!(
"file.runStartupScripts",
"Run Startup Scripts",
[],
None,
"{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}",
always,
run_startup
),
cmd!(
"file.runShutdownScripts",
"Run Shutdown Scripts",
[],
None,
"{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}",
always,
run_shutdown
),
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
cmd!(
"script.settings.save",
+5 -1
View File
@@ -402,12 +402,16 @@ pub struct ScriptUi {
#[serde(skip)]
pub tasks: Option<ScriptTaskRunner>,
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
/// tasks of scripts without an open window stop then.
/// tasks of scripts without an open window stop then, except those of Startup scripts.
#[serde(skip)]
pub project_epoch: u64,
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
#[serde(skip)]
pub task_hosts: Vec<u32>,
/// The script contexts of Startup scripts: their tasks go on when the project is closed or
/// replaced, until EffectCraft quits.
#[serde(skip)]
pub startup_hosts: Vec<u32>,
}
impl ScriptUi {
+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"));
+1
View File
@@ -17,6 +17,7 @@ mod include;
mod panels;
#[cfg(not(windows))]
mod services;
mod startup;
mod tasks;
mod ui;
mod ui_more;
+290
View File
@@ -0,0 +1,290 @@
//! 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 startup_scripts_tasks_go_on_after_a_project_opens() {
use web_time::{Duration, Instant};
let store = Arc::new(MemoryConfig::default());
store.write(&format!("{STARTUP_DIR}/A Poll.jsx"), "app.scheduleTask('writeLn(\"poll\")', 100, true);").unwrap();
// A task scheduled after the script's dialog closes.
store.write(&format!("{STARTUP_DIR}/B Ask.jsx"), "new Window('dialog', 'Ask').show();\napp.scheduleTask('writeLn(\"ask\")', 100, true);").unwrap();
let mut s = session(&store);
s.execute("file.runStartupScripts", json!({})).unwrap();
s.execute("scriptui.close", json!({})).unwrap();
// The launch opens a project after the Startup scripts ran.
s.execute("file.closeProject", json!({})).unwrap();
let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(500));
assert_eq!(r["ran"], json!(2), "{r}");
// A script run from File ▸ Scripts still stops when the project closes.
s.execute("script.run", json!({"code": "app.scheduleTask('writeLn(\"menu\")', 100, true);", "name": "Menu.jsx"})).unwrap();
s.execute("file.closeProject", json!({})).unwrap();
let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(5000));
assert_eq!(r["ran"], json!(2), "{r}");
}
#[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);
}
+10 -5
View File
@@ -404,8 +404,14 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv
// ---------------------------------------------------------------- scheduled tasks
/// Whether `host`'s tasks stop now that the project it ran with was closed: it has no window
/// open and is not a Startup script's.
fn ends_with_project(session: &Session, host: u32) -> bool {
!session.script_ui.windows.iter().any(|w| w.host == host) && !session.script_ui.startup_hosts.contains(&host)
}
/// Run the due tasks of the script-host threads; hosts without windows whose project was closed
/// end.
/// end, except Startup scripts' hosts.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
let epoch = session.script_ui.project_epoch;
@@ -413,8 +419,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
if let Ok(mut h) = hosts().lock() {
for host in session.script_ui.task_hosts.clone() {
let Some(end) = h.get(&host) else { continue };
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
if windowless && end.epoch != epoch {
if end.epoch != epoch && ends_with_project(session, host) {
// Dropping its end lets the host thread finish.
stale.extend(h.remove(&host));
} else if end.next_due.is_some_and(|d| d <= now) {
@@ -441,7 +446,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
}
/// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those
/// without windows whose project was closed stop.
/// without windows whose project was closed stop, except Startup scripts' hosts.
pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
for (host, name, epoch) in crate::tasks::hosts() {
if !session.script_ui.task_hosts.contains(&host) {
@@ -449,7 +454,7 @@ pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crat
}
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host));
if !alive || (windowless && epoch != session.script_ui.project_epoch) {
if !alive || (epoch != session.script_ui.project_epoch && ends_with_project(session, host)) {
crate::tasks::drop_host(host);
if host != CONSOLE_HOST && windowless {
drop_inline(host);
+27 -1
View File
@@ -16,7 +16,16 @@ use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Commands that replace or close the open project (Quit is asked when the window closes).
const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"];
const CLOSING: &[&str] = &[
"file.newProject",
"file.open",
"file.openRecent",
"file.closeProject",
"file.revert",
"file.openDemoProject",
"templates.create",
"file.runShutdownScripts",
];
/// The command waiting on the prompt, and whether the user already answered it.
#[derive(Clone, Debug, Default)]
@@ -239,6 +248,23 @@ mod tests {
assert!(app.session.is_dirty());
}
#[test]
fn shutdown_scripts_prompt_before_discarding_unsaved_work() {
let (mut app, ctx) = app();
modified(&mut app);
let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap();
assert_eq!(result, json!({"dialog": "unsavedChanges"}));
assert_eq!(app.session.project.comps().count(), 1);
assert!(app.session.is_dirty());
answer_with(&mut app, &ctx, Answer::Cancel);
assert_eq!(app.session.project.comps().count(), 1);
assert!(app.session.is_dirty());
crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap();
answer_with(&mut app, &ctx, Answer::Discard);
assert_eq!(app.session.project.comps().count(), 0);
assert!(!app.session.is_dirty() && app.dialog.is_none());
}
#[test]
fn closing_a_modified_project_asks_and_cancel_keeps_it() {
let (mut app, ctx) = app();
+3 -1
View File
@@ -583,7 +583,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl
`"waiting": true`, and the script continues (its final output arrives in the reply of the click
that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`,
`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`,
`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`:
`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and
`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes
the project first). Scripts' `app.settings`:
`script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`.
Extension points and the bundled extensions: [plugins.md](plugins.md).
+4 -1
View File
@@ -310,7 +310,10 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins
([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI
panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel
and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard
and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop
app runs the Shutdown folder's scripts as it quits. Missing: a global scope that scripts share
for the session (a Startup script's variables and functions are not available to later
scripts), keyboard
shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759,
[scripting-parity.md](scripting-parity.md)).
- Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept
+5 -1
View File
@@ -16,7 +16,7 @@ Guide):
| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
| File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same |
| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same |
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. 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 script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts |
| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap |
| Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points |
@@ -39,6 +39,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs
file.jsx`, or `script.run {code, name}` over the control channel and MCP.
* Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings
folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart).
* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the
settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and
`file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at
launch and quit, and the web app calls `file.runStartupScripts` at launch.
* `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs
one; `file.uninstallScript {name}` removes an installed one.
* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts
+3 -3
View File
@@ -21,9 +21,9 @@ our own scripts; no real-world script library has been run against both apps.
| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) |
| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging |
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) |
| Startup / Shutdown folders | yes | not run | missing | plugins.md |
| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) |
| `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths |
| `.jsxbin` | yes | no | out of scope | No public specification |
| Keyboard shortcuts for scripts | yes | no | missing | plugins.md |
@@ -35,7 +35,7 @@ our own scripts; no real-world script library has been run against both apps.
## Estimate
**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h,
**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h,
a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression
performance benchmark 4–8 h.
+1 -1
View File
@@ -117,7 +117,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for
| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed |
| Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed |
| Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) |
| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) |
| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) |
| Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) |
| Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) |
| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) |