//! The boa runtime: native functions (`__exec`, `__query`, `__print`, `__undo`, `__task`, //! `__file`), the session hand-off while a script runs, undo groups and error reporting. use std::cell::RefCell; use std::mem::ManuallyDrop; use std::sync::Arc; use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; use effectcraft_engine::project::Project; use effectcraft_engine::{ScriptRequest, Session}; use serde_json::{Value as J, json}; const PRELUDE: &str = include_str!("prelude.js"); /// Engine commands that create layers: like After Effects, scripts add new layers at the top of /// the stack (the UI inserts above the selection). const CREATES_LAYER: &[&str] = &["layer.newSolid", "layer.newNull", "layer.newText", "layer.newShape", "layer.newAdjustment", "layer.newCamera", "layer.newLight", "layer.addItem"]; /// What a script run produced. #[derive(Clone, Debug, Default, PartialEq)] pub struct Outcome { /// The value of the script's last expression statement (JSON when it converts; model objects /// as strings like `"[object CompItem]"`). pub result: J, /// `writeLn` / `$.writeln` / `alert` output, one entry per line. pub output: Vec, pub error: Option, } /// An uncaught script error. #[derive(Clone, Debug, Default, PartialEq)] pub struct ScriptError { pub message: String, pub line: Option, pub column: Option, pub file: String, } impl Outcome { pub fn ok(&self) -> bool { self.error.is_none() } pub fn to_json(&self) -> J { json!({ "ok": self.ok(), "result": self.result, "output": self.output.join("\n"), "error": self.error.as_ref().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})), }) } } /// The session and per-run state while a script runs (the natives reach it here; nothing /// borrowed ever enters the JS heap). struct Active { session: Session, output: Vec, /// Open undo groups: (name, project snapshot when the outermost group began). groups: Vec<(String, Arc)>, /// `app.scheduleTask` queue: (id, code, repeat). tasks: Vec<(u32, String, bool)>, next_task: u32, name: String, } thread_local! { static ACTIVE: RefCell> = const { RefCell::new(None) }; /// The Script Console's persistent context (never dropped, like the expression runtime: boa's /// own thread-local heap may be torn down first at thread exit). static CONSOLE: RefCell>> = const { RefCell::new(None) }; } fn with_active(f: impl FnOnce(&mut Active) -> Result) -> Result { ACTIVE.with(|a| match a.try_borrow_mut() { Ok(mut g) => match g.as_mut() { Some(act) => f(act), None => Err("no script is running".into()), }, Err(_) => Err("scripts can't run scripts".into()), }) } fn throw(msg: impl Into) -> boa_engine::JsError { JsNativeError::error().with_message(msg.into()).into() } fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped()) } fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { let s = arg_string(args, i, ctx)?; if s.is_empty() || s == "undefined" { return Ok(json!({})); } serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}"))) } fn js_str(s: &str) -> JsValue { JsValue::from(JsString::from(s)) } /// `__exec(commandId, paramsJson)` → result JSON. Runs an engine command (undoable) with the /// target comp made active for its duration. fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; let params = arg_json(args, 1, ctx)?; if id == "script.run" || id == "file.runScript" { return Err(throw("scripts can't run scripts")); } let r = with_active(|a| Ok(exec(&mut a.session, &id, params))); match r { Ok(Ok(v)) => Ok(js_str(&v.to_string())), Ok(Err(e)) | Err(e) => Err(throw(e)), } } /// Run a command for a script: the `comp` it targets is active while it runs (commands act on /// the active comp), then the user's active comp comes back. pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { use effectcraft_engine::project::ItemId; let target = params.get("comp").and_then(J::as_u64).map(ItemId).filter(|c| s.project.comp(*c).is_some()); let prev = s.state.active_comp; if let Some(c) = target && prev != Some(c) { s.state.active_comp = Some(c); } if CREATES_LAYER.contains(&id) { s.state.selected_layers.clear(); } let r = s.execute(id, params); if let Some(c) = target && prev != Some(c) && s.state.active_comp == Some(c) && id != "comp.open" { match prev { Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p), _ => { if !s.state.open_comps.contains(&c) { s.state.open_comps.push(c); } } } } r.map_err(|e| e.to_string()) } /// `__query(kind, json)` → JSON. fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let kind = arg_string(args, 0, ctx)?; let a = arg_json(args, 1, ctx)?; let r = with_active(|act| match kind.as_str() { "app" => Ok(json!({ "version": format!("26.0 (EffectCraft {})", env!("CARGO_PKG_VERSION")), "buildName": env!("CARGO_PKG_VERSION"), "scriptName": act.name, "allowFiles": act.session.prefs.scripting.allow_scripts_write_files, "projectDir": project_dir(&act.session), })), _ => crate::model::query(&mut act.session, &kind, &a), }); match r { Ok(v) => Ok(js_str(&v.to_string())), Err(e) => Err(throw(e)), } } /// `__print(text)`: `writeLn`, `$.writeln`, `alert`. fn native_print(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let text = arg_string(args, 0, ctx)?; let _ = with_active(|a| { a.output.extend(text.split('\n').map(str::to_string)); Ok(()) }); Ok(JsValue::undefined()) } /// `__undo("begin" | "end", name)`: After Effects' `app.beginUndoGroup` / `endUndoGroup`. Every /// command run inside the outermost group folds into one undo step named after the group. fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = arg_string(args, 0, ctx)?; let name = arg_string(args, 1, ctx)?; with_active(|a| { match op.as_str() { "begin" => { let snap = a.session.project.clone(); a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap)); } _ => { if a.groups.len() == 1 { let (name, snap) = a.groups.pop().unwrap_or_default(); collapse(&mut a.session, &name, &snap); } else { a.groups.pop(); } } } Ok(()) }) .map_err(throw)?; Ok(JsValue::undefined()) } /// Fold the undo steps recorded since the project was `snap` into one step called `name`. fn collapse(s: &mut Session, name: &str, snap: &Arc) { let h = &mut s.history; let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { // Nothing was recorded (or history was reset by New / Open Project). return; }; let first = h.undo[start].1.clone(); h.undo.truncate(start); h.undo.push((name.to_string(), first)); h.merge_key = None; } /// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`. fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = arg_string(args, 0, ctx)?; if op == "cancel" { let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; let _ = with_active(|a| { a.tasks.retain(|t| t.0 != id); Ok(()) }); return Ok(JsValue::undefined()); } let code = arg_string(args, 1, ctx)?; let repeat = args.get(2).cloned().unwrap_or_default().to_boolean(); let id = with_active(|a| { a.next_task += 1; let id = a.next_task; a.tasks.push((id, code, repeat)); Ok(id) }) .map_err(throw)?; Ok(JsValue::from(id)) } fn project_dir(s: &Session) -> Option { let p = s.path.as_deref()?; std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string()) } /// Lexically normalized absolute path (`a/./b/../c` → `a/c`). fn normalize(p: &str) -> std::path::PathBuf { use std::path::{Component, Path, PathBuf}; let path = Path::new(p); let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) }; let mut out = PathBuf::new(); for c in abs.components() { match c { Component::ParentDir => { out.pop(); } Component::CurDir => {} c => out.push(c), } } // Resolve symlinks where the file (or its folder) exists, so links can't escape the project. std::fs::canonicalize(&out) .or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default()))) .unwrap_or(out) } const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; /// The file-access gate: reads under the project's folder are always allowed; other reads, all /// writes and listing other folders need the Allow Scripts to Write Files preference. fn check_access(s: &Session, path: &str, write: bool) -> Result { let p = normalize(path); if s.prefs.scripting.allow_scripts_write_files { return Ok(p); } if write { return Err(format!("Unable to write {path}: scripts can't write files unless {GATE} is on")); } if let Some(dir) = project_dir(s) { let d = normalize(&dir); if p.starts_with(&d) { return Ok(p); } } Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on")) } /// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` → /// names, `mkdir`, `remove`, `isDir`. fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = arg_string(args, 0, ctx)?; let path = arg_string(args, 1, ctx)?; let data = if args.len() > 2 { arg_string(args, 2, ctx)? } else { String::new() }; let r: Result = with_active(|a| { let s = &a.session; match op.as_str() { "access" => Ok(json!(check_access(s, &path, false)?.exists())), "exists" | "isDir" => { // Unreadable locations report "missing" instead of failing. let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) }; Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() })) } "read" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; Ok(json!(String::from_utf8_lossy(&bytes))) } "write" | "append" => { let p = check_access(s, &path, true)?; let ps = p.to_string_lossy().to_string(); let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] }; bytes.extend_from_slice(data.as_bytes()); s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?; Ok(json!(bytes.len())) } "list" => { let p = check_access(s, &path, false)?; let mut names: Vec = std::fs::read_dir(&p) .map_err(|e| format!("Unable to list {path}: {e}"))? .filter_map(|e| e.ok()) .map(|e| e.path().to_string_lossy().to_string()) .collect(); names.sort(); Ok(json!(names)) } "mkdir" => { let p = check_access(s, &path, true)?; std::fs::create_dir_all(&p).map_err(|e| format!("Unable to create {path}: {e}"))?; Ok(json!(true)) } "remove" => { let p = check_access(s, &path, true)?; let r = if p.is_dir() { std::fs::remove_dir(&p) } else { std::fs::remove_file(&p) }; Ok(json!(r.is_ok())) } "network" => Err(if s.prefs.scripting.allow_scripts_write_files { "network access from scripts is not supported in EffectCraft".to_string() } else { format!("scripts can't access the network unless {GATE} is on") }), _ => Err(format!("unknown file operation `{op}`")), } }); match r { Ok(v) => Ok(js_str(&v.to_string())), Err(e) => Err(throw(e)), } } fn new_context() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 6] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), (js_string!("__task"), 3, native_task), (js_string!("__file"), 3, native_file), ]; for (name, len, f) in natives { ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; } ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("script prelude: {e}"))?; Ok(ctx) } /// Turn a boa error into a message with line and column (`code` is the user's script). fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError { // Thrown values: `throw new Error("x")`, `throw "x"`, native errors. let display = e.to_string(); let message = match e.as_opaque() { Some(v) => { let o = v.as_object(); let msg = o.as_ref().and_then(|o| o.get(js_string!("message"), ctx).ok()).filter(|m| !m.is_undefined()); let name = o.as_ref().and_then(|o| o.get(js_string!("name"), ctx).ok()).filter(|m| !m.is_undefined()); match (name, msg) { (Some(n), Some(m)) => { let n = n.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default(); let m = m.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default(); if n == "Error" { m } else { format!("{n}: {m}") } } _ => v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_else(|_| display.clone()), } } None => { let first = display.lines().next().unwrap_or("").trim(); let first = first.split(" at line ").next().unwrap_or(first).trim(); let first = match first.rfind(" (") { Some(i) if first.ends_with(')') && first[i..].contains(':') => first[..i].trim(), _ => first, }; first.strip_prefix("Error: ").unwrap_or(first).to_string() } }; let (line, column) = position(&display, file, code); ScriptError { message, line, column, file: file.to_string() } } /// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace /// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of /// the undefined name in `code`. fn position(msg: &str, file: &str, code: &str) -> (Option, Option) { if let Some(i) = msg.find("at line ") { let rest = &msg[i + 8..]; let line = rest.split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse().ok()); let col = rest.find("col ").and_then(|j| rest[j + 4..].split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()); return (line, col); } // `(file:12:5)`: the digits after the file name. let pick = |l: &str| -> Option<(u32, u32)> { let i = l.find(&format!("({file}:"))? + file.len() + 2; let mut it = l[i..].split([':', ')']); Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?)) }; let frames: Vec<&str> = msg.lines().skip(1).collect(); if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) { return (Some(a), Some(b)); } // `ReferenceError: name is not defined` without a position. let first = msg.lines().next().unwrap_or(""); if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) { for (n, line) in code.lines().enumerate() { let b = line.as_bytes(); let mut from = 0; while let Some(i) = line[from..].find(name).map(|i| i + from) { let before = i.checked_sub(1).map(|j| b[j]); let after = b.get(i + name.len()).copied(); let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) { return (Some(n as u32 + 1), Some(i as u32 + 1)); } from = i + name.len(); } } } (None, None) } /// Evaluate `code` in `ctx` and convert the result. fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result { let src = Source::from_bytes(code.as_bytes()).with_path(std::path::Path::new(file)); let script = boa_engine::Script::parse(src, None, ctx).map_err(|e| script_error(&e, ctx, file, code))?; let v = script.evaluate(ctx).map_err(|e| script_error(&e, ctx, file, code))?; Ok(result_json(&v, ctx)) } /// The script's completion value as JSON (`__result` in the prelude does the conversion). fn result_json(v: &JsValue, ctx: &mut Context) -> J { if v.is_undefined() { return J::Null; } let global = ctx.global_object(); let Some(f) = global.get(js_string!("__result"), ctx).ok().and_then(|f| f.as_object()) else { return J::Null }; match f.call(&JsValue::undefined(), std::slice::from_ref(v), ctx) { Ok(s) if s.is_string() => { let s = s.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default(); serde_json::from_str(&s).unwrap_or(J::String(s)) } _ => J::Null, } } /// Restores the caller's session when a run ends (on every exit path). struct Restore<'a> { target: &'a mut Session, } impl Drop for Restore<'_> { fn drop(&mut self) { if let Some(a) = ACTIVE.with(|c| c.borrow_mut().take()) { *self.target = a.session; } } } /// Run a script against `session`: the object model sees and edits that session through engine /// commands. Script errors come back in [`Outcome::error`]. pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome { let running = ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)); if running { return Outcome { error: Some(ScriptError { message: "scripts can't run scripts".into(), file: req.name.into(), ..Default::default() }), ..Default::default() }; } let s = std::mem::take(session); ACTIVE.with(|a| *a.borrow_mut() = Some(Active { session: s, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: req.name.to_string() })); let guard = Restore { target: session }; let file = if req.name.is_empty() { "script" } else { req.name }; let mut result = if req.console { CONSOLE.with(|c| { let mut c = c.borrow_mut(); if c.is_none() { match new_context() { Ok(ctx) => *c = Some(ManuallyDrop::new(ctx)), Err(e) => return Err(ScriptError { message: e, file: file.into(), ..Default::default() }), } } let ctx = c.as_mut().map(|m| &mut **m).expect("console context"); eval_in(ctx, req.code, file) }) } else { match new_context() { Ok(mut ctx) => { let r = eval_in(&mut ctx, req.code, file); // app.scheduleTask: run the queued one-shot tasks after the script, in order. let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default(); let mut r = r; for (_, code, repeat) in tasks { if repeat { continue; } if let Err(e) = eval_in(&mut ctx, &code, "scheduled task") && r.is_ok() { r = Err(e); } } r } Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }), } }; // Close undo groups left open; drop selections that no longer exist. let output = with_active(|a| { if let Some((name, snap)) = a.groups.first().cloned() { collapse(&mut a.session, &name, &snap); a.groups.clear(); } a.session.sanitize_state(); Ok(std::mem::take(&mut a.output)) }) .unwrap_or_default(); drop(guard); let mut out = Outcome { output, ..Default::default() }; match result.as_mut() { Ok(v) => out.result = std::mem::take(v), Err(e) => out.error = Some(e.clone()), } out }