mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
#760: $.evalFile and a minimal BridgeTalk host identification
Problem: `$.evalFile` always threw, and `typeof BridgeTalk` was `undefined`, so frameworks such as DuAEF failed during initialisation when they load their modules or ask which application they run in. Fix: `$.evalFile(file[, timeout])` 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 same file gate, 16 MiB size cap and 32-deep nesting limit as `#include` (#759, shared `scriptfile.rs`), and the bracket-depth check runs before parsing. `BridgeTalk` names the host honestly (`appName` "effectcraft", its version, `appSpecifier`, `isRunning` / `getSpecifier` / `getStatus` / `getTargets` for ourselves only); `send()` throws a clear "not supported" error instead of messaging other applications. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9a7d3e7699
commit
88278ea871
6 files changed
+259
-6
No files matched your search
@@ -20,7 +20,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
|
||||
@@ -56,6 +56,7 @@
|
||||
pub mod matchnames;
|
||||
mod model;
|
||||
mod runtime;
|
||||
mod scriptfile;
|
||||
mod socket;
|
||||
mod ui;
|
||||
|
||||
@@ -90,3 +91,7 @@ mod tests_panels;
|
||||
mod tests_ui;
|
||||
#[cfg(test)]
|
||||
mod tests_ui_more;
|
||||
|
||||
// #760: `$.evalFile` and `BridgeTalk`.
|
||||
#[cfg(test)]
|
||||
mod tests_evalfile;
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) -> Active {
|
||||
Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None }
|
||||
Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None, loading: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,13 +293,13 @@ fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
Ok(JsValue::from(id))
|
||||
}
|
||||
|
||||
fn project_dir(s: &Session) -> Option<String> {
|
||||
pub(crate) fn project_dir(s: &Session) -> Option<String> {
|
||||
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 {
|
||||
pub(crate) 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) };
|
||||
@@ -405,18 +407,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"), 3, 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),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Script files a running script loads (`#include`, `$.evalFile`): where a name resolves, whether
|
||||
//! the script may read it, and the limits that keep hostile, huge or cyclic files from running
|
||||
//! away.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::runtime::{GATE, normalize, project_dir};
|
||||
|
||||
/// How deep loaded files may nest (a loaded file loading another…).
|
||||
pub(crate) const MAX_DEPTH: usize = 32;
|
||||
/// The largest single file a script may load.
|
||||
pub(crate) const MAX_FILE_BYTES: usize = 16 << 20;
|
||||
|
||||
/// The folder of the script file `name` runs from: `None` for the Script Console and for scripts
|
||||
/// run by name or from code, which have no file.
|
||||
pub(crate) fn script_dir(s: &Session, name: &str) -> Option<PathBuf> {
|
||||
let parent = Path::new(name).parent().filter(|p| !p.as_os_str().is_empty())?;
|
||||
s.services.exists(name).then(|| normalize(&parent.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// Resolve `name`: absolute paths as they are; relative ones against `dir` (the loading script's
|
||||
/// folder), then each of `search`. The first existing file wins; when none exists, the first
|
||||
/// candidate, so the read error names it.
|
||||
pub(crate) fn resolve(s: &Session, name: &str, dir: Option<&Path>, search: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
if Path::new(name).is_absolute() {
|
||||
return Ok(normalize(name));
|
||||
}
|
||||
let candidates: Vec<PathBuf> = dir.into_iter().chain(search.iter().map(PathBuf::as_path)).map(|d| normalize(&d.join(name).to_string_lossy())).collect();
|
||||
candidates
|
||||
.iter()
|
||||
.find(|c| s.services.exists(&c.to_string_lossy()))
|
||||
.or(candidates.first())
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("cannot find \"{name}\": the script has no folder to look in (use an absolute path or #includepath)"))
|
||||
}
|
||||
|
||||
/// The read gate for loaded script files: files under the running script's own folder (`root`) or
|
||||
/// the project's folder; anywhere with the Allow Scripts to Write Files preference.
|
||||
pub(crate) fn check_read(s: &Session, path: &Path, root: Option<&Path>) -> Result<(), String> {
|
||||
if s.prefs.scripting.allow_scripts_write_files {
|
||||
return Ok(());
|
||||
}
|
||||
let project = project_dir(s).map(|d| normalize(&d));
|
||||
if root.into_iter().chain(project.as_deref()).any(|d| path.starts_with(d)) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("Unable to read {}: scripts can only load files from their own or the project's folder unless {GATE} is on", path.display()))
|
||||
}
|
||||
|
||||
/// Read a script file as text (a UTF-8 byte order mark dropped), refusing files over
|
||||
/// [`MAX_FILE_BYTES`].
|
||||
pub(crate) fn read(s: &Session, path: &Path) -> Result<String, String> {
|
||||
let too_big = || format!("Unable to read {}: script files are limited to {} MiB", path.display(), MAX_FILE_BYTES >> 20);
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES as u64) {
|
||||
return Err(too_big());
|
||||
}
|
||||
let bytes = s.services.read_file(&path.to_string_lossy()).map_err(|e| format!("Unable to read {}: {e}", path.display()))?;
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
return Err(too_big());
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_string())
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 | 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*` |
|
||||
| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) |
|
||||
| Startup / Shutdown folders | yes | not run | missing | plugins.md |
|
||||
|
||||
Reference in new issue
Block a user