mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
The Ease Presets panel (#254) is modelled on a third-party panel, so, as with third-party tools in After Effects, it isn't core any more: it is `extensions/scriptui-panels/Ease Presets.jsx`, a ScriptUI panel bundled as an optional extension (Window ▸ Ease Presets.jsx), written only against the public scripting API. Clicking a preset eases every pair of neighbouring selected keyframes in one undo step (per dimension, along the motion path for spatial properties, the keys' other sides as they play); the fields edit the working curve, which the panel draws; From Keys, Save, Rename and Delete as before. The twelve built-in curves are unchanged. Scripting API fixes it needed (After Effects parity): - `app.settings` lasts between runs (`script_settings.json` in the settings folder; `script.settings.get` / `script.settings.save`). - Per-key methods (`setTemporalEaseAtKey`, `setInterpolationTypeAtKey`, `removeKey`, the continuous / auto-Bezier / roving setters) no longer change the user's key selection (they selected the key they edited). - `keyInTemporalEase` / `keyOutTemporalEase` report linear and auto-Bezier sides as they play, `keyIn/OutSpatialTangent` the tangents the motion path plays. - `selectedProperties` includes the properties of selected keyframes. - `isInterpolationTypeValid` is false for Linear / Bezier on values that only hold. Removed from core: Window ▸ Ease Presets, the `keys.easePreset.*` commands, `engine::ease_presets` and the `ease_presets.json` store, `keyframe::EaseCurve` (nothing else used it) and the egui panel. User presets saved by the v0.6.0 panel move into the panel's app.settings section the first time settings load (the old file is left as is). Docs: plugins.md (bundled extensions), parity.md, gaps.md G8, agents.md. Test: app_settings_last_between_runs, per_key_methods_keep_the_key_selection_and_report_eases_as_they_play, spatial_tangents_are_reported_as_the_path_plays (each fails before), the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step, a_typed_curve_scales_to_each_dimension_and_follows_motion_paths, user_presets_save_rename_delete_and_persist, presets_saved_by_the_old_core_panel_carry_over (script, through the panel's controls), ease_presets_panel_eases_the_selected_keys_from_its_list (UI: the panel docks and a click on a preset eases the keys). Co-Authored-By: Claude Opus 5.5 <[email protected]>
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(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());
|
|
}
|
|
}
|