mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
A running script reached `prefs.set` through `app.run` and could turn on Settings > Scripting & Expressions > Allow Scripts to Write Files and Access Network (or turn off Warn User When Executing Files), which the File, Socket and system.callSystem gates then read: the permission the user withheld was the script's to take. Commands a script issues now go through `Session::execute_for_script`, which marks the session as running for a script for the command and everything it runs (engine.batch, ScriptUI callbacks, scheduled tasks). While it is, `prefs.set` refuses any change that would leave the Application Scripting settings different (compared on the resulting settings, so dotted keys, whole-page objects and batches are all covered), and a script's `prefs.reset` keeps them as the user set them. Command scripts read from a file (File > Scripts > Run Script File... with .json/.jsonl) count as scripts too. The UI, CLI, control channel and MCP still change them. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
722 lines
30 KiB
Rust
722 lines
30 KiB
Rust
//! 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<String>,
|
|
pub error: Option<ScriptError>,
|
|
/// The script is waiting in a modal ScriptUI dialog's `show()` (it goes on when the dialog
|
|
/// closes; see [`crate::dispatch_ui`]).
|
|
pub waiting: bool,
|
|
}
|
|
|
|
/// An uncaught script error.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct ScriptError {
|
|
pub message: String,
|
|
pub line: Option<u32>,
|
|
pub column: Option<u32>,
|
|
pub file: String,
|
|
}
|
|
|
|
impl Outcome {
|
|
pub fn ok(&self) -> bool {
|
|
self.error.is_none()
|
|
}
|
|
pub fn to_json(&self) -> J {
|
|
let mut 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})),
|
|
});
|
|
if self.waiting {
|
|
j["waiting"] = json!(true);
|
|
}
|
|
j
|
|
}
|
|
}
|
|
|
|
/// The session and per-run state while a script runs (the natives reach it here; nothing
|
|
/// borrowed ever enters the JS heap).
|
|
pub(crate) struct Active {
|
|
pub(crate) session: Session,
|
|
pub(crate) output: Vec<String>,
|
|
/// Open undo groups: (name, project snapshot when the outermost group began).
|
|
pub(crate) groups: Vec<(String, Arc<Project>)>,
|
|
/// `app.scheduleTask` queue: (id, code, repeat).
|
|
pub(crate) tasks: Vec<(u32, String, bool)>,
|
|
pub(crate) next_task: u32,
|
|
pub(crate) name: String,
|
|
/// 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>,
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
pub(crate) static ACTIVE: RefCell<Option<Active>> = 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).
|
|
pub(crate) static CONSOLE: RefCell<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
pub(crate) fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
|
|
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()),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn throw(msg: impl Into<String>) -> boa_engine::JsError {
|
|
JsNativeError::error().with_message(msg.into()).into()
|
|
}
|
|
|
|
pub(crate) fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
|
|
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<J> {
|
|
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}")))
|
|
}
|
|
|
|
pub(crate) 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<JsValue> {
|
|
let id = arg_string(args, 0, ctx)?;
|
|
let mut params = arg_json(args, 1, ctx)?;
|
|
if id == "script.run" || id == "file.runScript" {
|
|
return Err(throw("scripts can't run scripts"));
|
|
}
|
|
let keys = params.as_object_mut().and_then(|o| o.remove("__keys"));
|
|
let r = with_active(|a| {
|
|
Ok(match &keys {
|
|
Some(k) => exec_on_keys(&mut a.session, &id, params, k),
|
|
None => 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<J, String> {
|
|
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_for_script(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())
|
|
}
|
|
|
|
/// Run a keyframe command that acts on the selected keys (`keys.velocity`, `keys.interpolation`,
|
|
/// `keys.delete`) on the keys `targets` names instead (`[{layer, prop, index}]`, 0-based, in the
|
|
/// command's comp): After Effects' per-key methods (`setTemporalEaseAtKey`, `removeKey`…) leave
|
|
/// the user's key selection as it was. Selected keys that no longer exist drop out of it.
|
|
fn exec_on_keys(s: &mut Session, id: &str, params: J, targets: &J) -> Result<J, String> {
|
|
use effectcraft_engine::KeyRef;
|
|
use effectcraft_engine::project::{ItemId, LayerId};
|
|
let cid = params.get("comp").and_then(J::as_u64).map(ItemId).or(s.state.active_comp).ok_or("no composition is open")?;
|
|
let comp = s.project.comp(cid).ok_or("the composition no longer exists")?;
|
|
let mut keys = vec![];
|
|
for t in targets.as_array().into_iter().flatten() {
|
|
let (Some(layer), Some(prop), Some(index)) = (t["layer"].as_u64(), t["prop"].as_u64(), t["index"].as_u64()) else {
|
|
return Err(format!("bad keyframe reference {t}"));
|
|
};
|
|
let l = comp.layer(LayerId(layer)).ok_or("the layer no longer exists")?;
|
|
if l.switches.locked {
|
|
return Err(format!("Can not change keyframes of \"{}\": the layer is locked", l.name));
|
|
}
|
|
let key = l.props.find(prop).and_then(|pr| pr.keys.get(usize::try_from(index).ok()?)).ok_or("the keyframe no longer exists")?;
|
|
keys.push(KeyRef { layer: LayerId(layer), prop, time: key.time });
|
|
}
|
|
let saved = std::mem::replace(&mut s.state.selected_keys, keys);
|
|
let r = exec(s, id, params);
|
|
let comp = s.active_comp();
|
|
let exists = |k: &KeyRef| comp.and_then(|c| c.layer(k.layer)).and_then(|l| l.props.find(k.prop)).is_some_and(|pr| pr.keys.iter().any(|x| x.time == k.time));
|
|
s.state.selected_keys = saved.into_iter().filter(exists).collect();
|
|
r
|
|
}
|
|
|
|
/// `__query(kind, json)` → JSON.
|
|
fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
|
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<JsValue> {
|
|
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<JsValue> {
|
|
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`.
|
|
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
|
|
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<JsValue> {
|
|
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<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 {
|
|
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)
|
|
}
|
|
|
|
pub(crate) 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<std::path::PathBuf, String> {
|
|
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<JsValue> {
|
|
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<J, String> = with_active(|a| {
|
|
// File.execute(): opened with its default application through `file.executeFile`
|
|
// (Allow Scripts to Write Files gate, Warn User When Executing Files confirmation).
|
|
if op == "execute" {
|
|
return exec(&mut a.session, "file.executeFile", json!({"path": path})).map(|v| json!(v.get("opened").is_some()));
|
|
}
|
|
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<String> = 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)),
|
|
}
|
|
}
|
|
|
|
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] = [
|
|
(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!("__sock"), 3, crate::socket::native_sock),
|
|
(js_string!("__uiNewId"), 0, crate::ui::native_new_id),
|
|
(js_string!("__uiModal"), 1, crate::ui::native_modal),
|
|
(js_string!("__uiLayoutNative"), 1, crate::ui::native_layout),
|
|
];
|
|
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}"))?;
|
|
ctx.eval(Source::from_bytes(crate::ui::SCRIPTUI)).map_err(|e| format!("ScriptUI prelude: {e}"))?;
|
|
Ok(ctx)
|
|
}
|
|
|
|
/// Turn a boa error into a message with line and column (`code` is the user's script).
|
|
pub(crate) 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<u32>, Option<u32>) {
|
|
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.
|
|
pub(crate) fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
|
|
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).
|
|
pub(crate) struct Restore<'a> {
|
|
pub(crate) 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// End of a script step: close undo groups left open, drop selections that no longer exist,
|
|
/// and take the output so far.
|
|
pub(crate) fn settle() -> Vec<String> {
|
|
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()
|
|
}
|
|
|
|
/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`).
|
|
pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
|
|
let mut r = eval_in(ctx, code, file);
|
|
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
|
|
for (_, code, repeat) in tasks {
|
|
if repeat {
|
|
continue;
|
|
}
|
|
if let Err(e) = eval_in(ctx, &code, "scheduled task")
|
|
&& r.is_ok()
|
|
{
|
|
r = Err(e);
|
|
}
|
|
}
|
|
r
|
|
}
|
|
|
|
pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> Outcome {
|
|
let mut out = Outcome { output, ..Default::default() };
|
|
match result {
|
|
Ok(v) => out.result = v,
|
|
Err(e) => out.error = Some(e),
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Run a script against `session`: the object model sees and edits that session through engine
|
|
/// commands. Script errors come back in [`Outcome::error`].
|
|
///
|
|
/// On native builds a script file runs on its own script-host thread, so a ScriptUI dialog's
|
|
/// `show()` can wait for the user (the session comes back here meanwhile, and the dialog's
|
|
/// events reach the host through [`crate::dispatch_ui`]); palettes and panels keep their host
|
|
/// alive for their handlers. The Script Console (and the web build) run inline, where dialogs
|
|
/// don't block.
|
|
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
|
if let Err(message) = check_nesting(req.code) {
|
|
return Outcome { error: Some(ScriptError { message, file: req.name.into(), ..Default::default() }), ..Default::default() };
|
|
}
|
|
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()
|
|
};
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if !req.console {
|
|
return crate::ui::run_threaded(session, req);
|
|
}
|
|
run_inline(session, req)
|
|
}
|
|
|
|
/// The deepest bracket nesting a script may have. The JavaScript parser recurses per level and
|
|
/// overflows the stack (aborting the app, which can't be caught) at about 200 levels on an
|
|
/// 8 MiB thread such as the Script Console's, so deeper scripts are rejected up front.
|
|
pub const MAX_NESTING: usize = 96;
|
|
|
|
/// An error when `code` nests brackets deeper than [`MAX_NESTING`] (strings, template literals
|
|
/// and comments are skipped).
|
|
pub fn check_nesting(code: &str) -> Result<(), String> {
|
|
let b = code.as_bytes();
|
|
let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize);
|
|
while let Some(&c) = b.get(i) {
|
|
match c {
|
|
b'(' | b'[' | b'{' => {
|
|
depth += 1;
|
|
max = max.max(depth);
|
|
}
|
|
b')' | b']' | b'}' => depth = depth.saturating_sub(1),
|
|
b'"' | b'\'' | b'`' => {
|
|
// Skip to the closing quote (escapes skip a byte).
|
|
i += 1;
|
|
while let Some(&d) = b.get(i) {
|
|
if d == b'\\' {
|
|
i += 1;
|
|
} else if d == c || (d == b'\n' && c != b'`') {
|
|
break;
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
b'/' if b.get(i + 1) == Some(&b'/') => {
|
|
while b.get(i).is_some_and(|d| *d != b'\n') {
|
|
i += 1;
|
|
}
|
|
}
|
|
b'/' if b.get(i + 1) == Some(&b'*') => {
|
|
i += 2;
|
|
while i < b.len() && !(b.get(i) == Some(&b'*') && b.get(i + 1) == Some(&b'/')) {
|
|
i += 1;
|
|
}
|
|
i += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
if max > MAX_NESTING {
|
|
return Err(format!("Error: the script is nested too deeply ({max} levels of brackets; at most {MAX_NESTING})"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Run on this thread (the Script Console's persistent context, or a fresh one).
|
|
pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
|
let host = if req.console { crate::ui::CONSOLE_HOST } else { crate::ui::new_host_id() };
|
|
let s = std::mem::take(session);
|
|
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name)));
|
|
let guard = Restore { target: session };
|
|
let file = if req.name.is_empty() { "script" } else { req.name };
|
|
let (result, windows) = 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() }), vec![]),
|
|
}
|
|
}
|
|
let Some(ctx) = c.as_mut().map(|m| &mut **m) else {
|
|
return (Err(ScriptError { message: "the console context is unavailable".into(), file: file.into(), ..Default::default() }), vec![]);
|
|
};
|
|
let r = eval_in(ctx, req.code, file);
|
|
(r, crate::ui::snapshot(ctx))
|
|
})
|
|
} else {
|
|
match new_context() {
|
|
Ok(mut ctx) => {
|
|
let r = eval_with_tasks(&mut ctx, req.code, file);
|
|
let windows = crate::ui::snapshot(&mut ctx);
|
|
if windows.iter().any(|w| w.visible) {
|
|
// Palettes and panels outlive the run: keep their context for the handlers.
|
|
crate::ui::keep_inline(host, ctx);
|
|
}
|
|
(r, windows)
|
|
}
|
|
Err(e) => (Err(ScriptError { message: e, file: file.into(), ..Default::default() }), vec![]),
|
|
}
|
|
};
|
|
let output = settle();
|
|
drop(guard);
|
|
crate::ui::publish(session, host, req.name, windows);
|
|
outcome(output, result)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod nesting_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn deep_nesting_is_an_error_not_a_stack_overflow() {
|
|
// 100 000 nested brackets overflowed the parser's stack and aborted the process.
|
|
let mut s = Session::default();
|
|
for code in ["(".repeat(100_000) + "1" + &")".repeat(100_000), "[".repeat(100_000), "{".repeat(MAX_NESTING + 1)] {
|
|
let out = run(&mut s, &ScriptRequest { code: &code, name: "deep", console: true });
|
|
assert!(out.error.is_some_and(|e| e.message.contains("nested too deeply")));
|
|
}
|
|
// Brackets in strings and comments don't count; ordinary nesting runs.
|
|
let quoted = format!("var s = '{}'; // {}\n/* {} */ 1 + 1", "(".repeat(500), "[".repeat(500), "{".repeat(500));
|
|
assert!(check_nesting("ed).is_ok());
|
|
let out = run(&mut s, &ScriptRequest { code: "ed, name: "ok", console: true });
|
|
assert!(out.error.is_none(), "{:?}", out.error);
|
|
assert_eq!(out.result, serde_json::json!(2));
|
|
assert!(check_nesting(&("(".repeat(MAX_NESTING) + &")".repeat(MAX_NESTING))).is_ok());
|
|
}
|
|
}
|