Merge pull request #772 from vangel76/script-760-evalfile-bridgetalk

#760: $.evalFile and a minimal BridgeTalk host identification

# Conflicts:
#	crates/script/src/runtime.rs
#	docs/scripting-parity.md
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:20:49 -04:00
commit dfd6e9300f
5 files changed
+191 -4

No files matched your search

+5 -1
View File
@@ -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;
+42 -1
View File
@@ -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; }
+44 -2
View File
@@ -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<crate::ui::Link>,
/// The files `$.evalFile` is evaluating, outermost first.
pub(crate) loading: Vec<std::path::PathBuf>,
}
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<JsV
}
}
/// `__evalFile(path)`: ExtendScript's `$.evalFile`. Evaluates a script file in the global scope
/// and returns its last value. Relative paths resolve against the folder of the file being
/// evaluated (the running script's for the outermost call, else the project's); reads follow the
/// file gate `#include` uses ([`crate::scriptfile`]) and calls nest at most
/// [`MAX_DEPTH`](crate::scriptfile::MAX_DEPTH) deep.
fn native_eval_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
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<Context, String> {
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<JsValue>); 10] = [
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 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),
+99
View File
@@ -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:?}");
}
+1
View File
@@ -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) |