diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 0b612d7b..efa14f7d 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -21,7 +21,7 @@ //! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes, //! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`, //! `MarkerValue`, `RenderQueue`/`RenderQueueItem`/`OutputModule`, `ImportOptions`, `File`/ -//! `Folder`, `$`, `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`, +//! `Folder`, `$` (with `evalFile`), `BridgeTalk` (host identification), `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`, //! `TrackMatteType`, `LightType`, `ParagraphJustification`, `PropertyValueType`…). //! * **Edits are engine commands**: every mutating call runs a command through //! [`Session::execute`](effectcraft_engine::Session::execute), so it is undoable, journaled @@ -102,3 +102,7 @@ mod tests_tasks; mod tests_ui; #[cfg(test)] mod tests_ui_more; + +// #760: `$.evalFile` and `BridgeTalk`. +#[cfg(test)] +mod tests_evalfile; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 240be8ed..18770041 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -111,9 +111,50 @@ var $ = { get os() { return "EffectCraft"; }, get version() { return "EffectCraft scripting"; }, get locale() { return "en_US"; }, - evalFile: function () { throw __err("$.evalFile is not supported: scripts can't run scripts"); }, + // Evaluates the file in the global scope and returns its last value (`timeout` is ignored). + evalFile: function (file, timeout) { return __evalFile(__str(file)); }, }; +// ------------------------------------------------------------------ BridgeTalk + +// Which application a script runs in, for scripts and frameworks that ask during initialisation. +// EffectCraft names itself rather than posing as another application; messages to other +// applications aren't supported. +function BridgeTalk() { + if (!(this instanceof BridgeTalk)) return new BridgeTalk(); + this.target = ""; + this.sender = BridgeTalk.appSpecifier; + this.body = ""; + this.type = "ExtendScript"; + this.headers = {}; + this.timeout = 0; + this.onResult = null; + this.onError = null; + this.onReceived = null; + this.onTimeout = null; +} +BridgeTalk.prototype.send = function () { + throw __err("BridgeTalk: sending messages to other applications is not supported in EffectCraft"); +}; +BridgeTalk.prototype.sendResult = BridgeTalk.prototype.send; +BridgeTalk.prototype.toString = function () { return "[object BridgeTalk]"; }; +BridgeTalk.appName = "effectcraft"; +Object.defineProperty(BridgeTalk, "appVersion", { get: function () { return __get("app").buildName; } }); +Object.defineProperty(BridgeTalk, "appSpecifier", { get: function () { return "effectcraft-" + BridgeTalk.appVersion; } }); +BridgeTalk.appLocale = "en_US"; +BridgeTalk.appInstance = ""; +BridgeTalk.appStatus = "idle"; +BridgeTalk.onReceive = null; +BridgeTalk.__isSelf = function (spec) { return String(spec === undefined || spec === null ? "" : spec).split("-")[0].toLowerCase() === "effectcraft"; }; +BridgeTalk.isRunning = function (spec) { return BridgeTalk.__isSelf(spec); }; +BridgeTalk.launch = function (spec) { return BridgeTalk.__isSelf(spec); }; +BridgeTalk.getSpecifier = function (name) { return BridgeTalk.__isSelf(name) ? BridgeTalk.appSpecifier : null; }; +BridgeTalk.getDisplayName = function (spec) { return BridgeTalk.__isSelf(spec) ? "EffectCraft" : null; }; +BridgeTalk.getTargets = function () { return [BridgeTalk.appName]; }; +BridgeTalk.getStatus = function (spec) { return spec === undefined || BridgeTalk.__isSelf(spec) ? "IDLE" : "ISNOTINSTALLED"; }; +BridgeTalk.bringToFront = function () {}; +BridgeTalk.pump = function () { return false; }; + // ------------------------------------------------------------------ File / Folder function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; } diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index dba96768..ffcf90af 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -72,11 +72,13 @@ pub(crate) struct Active { /// Running on a script-host thread: modal dialogs hand the session back to the caller /// through this link while they wait. pub(crate) link: Option, + /// The files `$.evalFile` is evaluating, outermost first. + pub(crate) loading: Vec, } impl Active { pub(crate) fn new(session: Session, name: &str, host: u32) -> Active { - Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None } + Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, loading: vec![] } } } @@ -411,18 +413,58 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + use crate::scriptfile::{self, MAX_DEPTH}; + let name = arg_string(args, 0, ctx)?; + if name.is_empty() || name == "undefined" { + return Err(throw("$.evalFile needs a file")); + } + let (path, text) = with_active(|a| { + if a.loading.len() >= MAX_DEPTH { + return Err(format!("$.evalFile(\"{name}\"): files are nested too deeply (at most {MAX_DEPTH})")); + } + let s = &a.session; + let root = scriptfile::script_dir(s, &a.name); + let dir = a + .loading + .last() + .and_then(|p| p.parent().map(std::path::Path::to_path_buf)) + .or_else(|| root.clone()) + .or_else(|| project_dir(s).map(|d| normalize(&d))); + let path = scriptfile::resolve(s, &name, dir.as_deref(), &[]).map_err(|e| format!("$.evalFile {e}"))?; + scriptfile::check_read(s, &path, root.as_deref())?; + let text = scriptfile::read(s, &path)?; + check_nesting(&text)?; + a.loading.push(path.clone()); + Ok((path, text)) + }) + .map_err(throw)?; + let r = ctx.eval(Source::from_bytes(text.as_bytes()).with_path(&path)); + let _ = with_active(|a| { + a.loading.pop(); + Ok(()) + }); + r +} + pub(crate) 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); 10] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 11] = [ (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"), 4, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__evalFile"), 2, native_eval_file), (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), (js_string!("__uiModal"), 1, crate::ui::native_modal), diff --git a/crates/script/src/tests_evalfile.rs b/crates/script/src/tests_evalfile.rs new file mode 100644 index 00000000..c97a57b6 --- /dev/null +++ b/crates/script/src/tests_evalfile.rs @@ -0,0 +1,99 @@ +//! #760: `$.evalFile` and the `BridgeTalk` host identification. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::Session; +use serde_json::json; + +use crate::{Outcome, run_code}; + +fn session() -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s +} + +fn tmp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-evalfile-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +/// Run the script file `main` (its code read from disk, as File ▸ Scripts ▸ Run Script File does). +fn run_file(s: &mut Session, main: &Path) -> Outcome { + let code = std::fs::read_to_string(main).unwrap(); + run_code(s, &code, &main.to_string_lossy()) +} + +#[test] +fn eval_file_evaluates_relative_to_the_calling_script() { + let dir = tmp("ok"); + // lib/a.jsx loads b.jsx next to itself, not next to main.jsx. + write(&dir.join("lib/a.jsx"), "var fromA = $.evalFile('b.jsx') + 1;\nfunction twice(x) { return 2 * x; }\nfromA"); + write(&dir.join("lib/b.jsx"), "var fromB = 20;\nfromB"); + write( + &dir.join("main.jsx"), + "var r = $.evalFile(new File($.fileName).path + '/lib/a.jsx');\nvar s = $.evalFile('lib/b.jsx');\n[r, s, twice(fromB), typeof fromA]", + ); + let mut s = session(); + let o = run_file(&mut s, &dir.join("main.jsx")); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!([21, 20, 40, "number"])); + // Errors in the evaluated file reach the caller, which can catch them. + write(&dir.join("bad.jsx"), "throw new Error('bad file');"); + write(&dir.join("main.jsx"), "var m; try { $.evalFile('bad.jsx'); } catch (e) { m = e.message; } m"); + assert_eq!(run_file(&mut s, &dir.join("main.jsx")).result, json!("bad file")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn eval_file_is_bounded_and_gated() { + let dir = tmp("hostile"); + // A file that evaluates itself forever stops at the depth limit. + write(&dir.join("loop.jsx"), "$.evalFile('loop.jsx');"); + let mut s = session(); + let e = run_file(&mut s, &dir.join("loop.jsx")).error.expect("an error"); + assert!(e.message.contains("nested too deeply"), "{e:?}"); + // Deeply bracketed files are refused before they reach the parser. + write(&dir.join("deep.jsx"), &"[".repeat(100_000)); + write(&dir.join("main.jsx"), "$.evalFile('deep.jsx')"); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.message.contains("nested too deeply"), "{e:?}"); + // Missing files are a clear error. + write(&dir.join("main.jsx"), "$.evalFile('missing.jsx')"); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.message.contains("missing.jsx"), "{e:?}"); + // Files outside the script's (and the project's) folder need the file preference. + write(&dir.join("outside.jsx"), "7"); + write(&dir.join("app/main.jsx"), "$.evalFile('../outside.jsx')"); + let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked"); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_file(&mut s, &dir.join("app/main.jsx")); + assert_eq!(o.result, json!(7), "{:?}", o.error); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bridgetalk_identifies_effectcraft() { + let mut s = session(); + let o = run_code( + &mut s, + "[typeof BridgeTalk, BridgeTalk.appName, BridgeTalk.appVersion, BridgeTalk.appSpecifier, BridgeTalk.isRunning('aftereffects'), \ + BridgeTalk.getSpecifier('effectcraft'), BridgeTalk.getStatus('photoshop'), new BridgeTalk().sender]", + "bt.jsx", + ); + assert!(o.error.is_none(), "{:?}", o.error); + let v = env!("CARGO_PKG_VERSION"); + let spec = format!("effectcraft-{v}"); + assert_eq!(o.result, json!(["function", "effectcraft", v, spec, false, spec, "ISNOTINSTALLED", spec])); + // Messages to other applications are unsupported, with a clear error. + let e = run_code(&mut s, "var bt = new BridgeTalk(); bt.target = 'photoshop'; bt.body = '1'; bt.send();", "bt.jsx").error.expect("unsupported"); + assert!(e.message.contains("not supported"), "{e:?}"); +} diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index d95c1245..66b57372 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -19,6 +19,7 @@ our own scripts; no real-world script library has been run against both apps. | Expression editing | Inline field, error bar, pick whip, Expression Language menu, external editors | Inline field that grows, error bar, pick whips (property and value), language menu | partial | External editor support (#364) | | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | 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 | 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 | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) |