588 lines
28 KiB
Rust
588 lines
28 KiB
Rust
//! File ▸ Scripts and ScriptUI.
|
||
//!
|
||
//! * **Installed scripts** live in the settings store under `Scripts/` (File ▸ Scripts ▸ Install
|
||
//! Script File…) and `Scripts/ScriptUI Panels/` (Install ScriptUI Panel…), next to the
|
||
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
|
||
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
|
||
//! panels appear at the bottom of the Window menu and open as dockable panels
|
||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
|
||
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
|
||
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
|
||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||
//! section and key that last between runs ([`SCRIPT_SETTINGS_FILE`]).
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use serde::Serialize;
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{CommandSpec, always, bad, str_p};
|
||
use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
||
|
||
/// The settings-store folders of installed scripts.
|
||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
|
||
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
|
||
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
|
||
/// keeps them in its preferences.
|
||
pub const SCRIPT_SETTINGS_FILE: &str = "script_settings.json";
|
||
/// User presets of the core Ease Presets panel of v0.6.0 (#254) and where the Ease Presets
|
||
/// ScriptUI panel that replaced it keeps them: (file, section, key). The file's text moves there
|
||
/// unchanged (the panel reads the same JSON) the first time settings load.
|
||
const LEGACY_EASE_PRESETS: (&str, &str, &str) = ("ease_presets.json", "Ease Presets", "userPresets");
|
||
/// Longest section, key and value a script may store (characters / bytes).
|
||
const MAX_SETTING_NAME: usize = 256;
|
||
const MAX_SETTING_VALUE: usize = 1 << 20;
|
||
|
||
/// Scripts' `app.settings`: values by section and key.
|
||
pub type ScriptSettings = BTreeMap<String, BTreeMap<String, String>>;
|
||
|
||
/// Load scripts' settings from the settings store (a corrupt file gives none), moving user ease
|
||
/// presets saved before Ease Presets became a ScriptUI panel into its section.
|
||
pub(crate) fn load_script_settings(s: &mut Session) {
|
||
let Some(cfg) = s.config.clone() else { return };
|
||
s.script_settings = cfg
|
||
.read(SCRIPT_SETTINGS_FILE)
|
||
.and_then(|t| serde_json::from_str(&t).map_err(|e| log::warn!("{SCRIPT_SETTINGS_FILE} is not valid ({e}); scripts start without their settings")).ok())
|
||
.unwrap_or_default();
|
||
let (file, section, key) = LEGACY_EASE_PRESETS;
|
||
if let Some(text) = cfg.read(file)
|
||
&& !s.script_settings.get(section).is_some_and(|m| m.contains_key(key))
|
||
{
|
||
s.script_settings.entry(section.into()).or_default().insert(key.into(), text);
|
||
if let Err(e) = store_script_settings(s) {
|
||
log::warn!("cannot move {file} into the Ease Presets panel's settings: {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
fn store_script_settings(s: &Session) -> Result<()> {
|
||
let Some(cfg) = &s.config else { return Ok(()) };
|
||
let text = serde_json::to_string_pretty(&s.script_settings).map_err(|e| EngineError::Other(format!("script settings: {e}")))?;
|
||
cfg.write(SCRIPT_SETTINGS_FILE, &text).map_err(|e| EngineError::Other(format!("cannot save script settings: {e}")))
|
||
}
|
||
|
||
fn setting_name<'a>(p: &'a Value, k: &str, c: &str) -> Result<&'a str> {
|
||
let v = str_p(p, k).ok_or_else(|| bad(c, format!("missing `{k}`")))?;
|
||
if v.chars().count() > MAX_SETTING_NAME {
|
||
return Err(bad(c, format!("`{k}` is longer than {MAX_SETTING_NAME} characters")));
|
||
}
|
||
Ok(v)
|
||
}
|
||
|
||
/// `script.settings.get {section, key}` → `{have, value}` (`value` "" when there is none).
|
||
fn settings_get(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "script.settings.get";
|
||
let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?);
|
||
let v = s.script_settings.get(section).and_then(|m| m.get(key));
|
||
Ok(json!({"have": v.is_some(), "value": v.cloned().unwrap_or_default()}))
|
||
}
|
||
|
||
/// `script.settings.save {section, key, value}`: kept in the settings store.
|
||
fn settings_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "script.settings.save";
|
||
let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?);
|
||
let value = match p.get("value") {
|
||
Some(Value::String(v)) => v.clone(),
|
||
Some(v) if !v.is_null() => v.to_string(),
|
||
_ => return Err(bad(C, "missing `value`")),
|
||
};
|
||
if value.len() > MAX_SETTING_VALUE {
|
||
return Err(bad(C, format!("`value` is larger than {MAX_SETTING_VALUE} bytes")));
|
||
}
|
||
s.script_settings.entry(section.into()).or_default().insert(key.into(), value);
|
||
store_script_settings(s)?;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// Scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, source, code).
|
||
/// `sample`s show how to script EffectCraft; `extension`s (in `extensions/`) are optional tools
|
||
/// built on the public scripting API, outside the core, like third-party scripts in After Effects.
|
||
pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
|
||
("Create Null at Selected Layers.jsx", false, "sample", include_str!("../../scripts/Create Null at Selected Layers.jsx")),
|
||
("Rename Layers.jsx", false, "sample", include_str!("../../scripts/Rename Layers.jsx")),
|
||
("Render Queue Batch.jsx", false, "sample", include_str!("../../scripts/Render Queue Batch.jsx")),
|
||
("Sort Layers by In Point.jsx", false, "sample", include_str!("../../scripts/Sort Layers by In Point.jsx")),
|
||
("Layer Tools.jsx", true, "sample", include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")),
|
||
("Ease Presets.jsx", true, "extension", include_str!("../../../../extensions/scriptui-panels/Ease Presets.jsx")),
|
||
];
|
||
|
||
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct ScriptEntry {
|
||
/// File name (`Rename Layers.jsx`): what the menus show and commands take.
|
||
pub name: String,
|
||
/// In the ScriptUI Panels folder: opens as a dockable panel.
|
||
pub panel: bool,
|
||
/// `installed`, `sample` or `extension` (bundled, see [`BUNDLED`]).
|
||
pub source: &'static str,
|
||
}
|
||
|
||
fn is_script(name: &str) -> bool {
|
||
let l = name.to_ascii_lowercase();
|
||
l.ends_with(".jsx") || l.ends_with(".js")
|
||
}
|
||
|
||
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
|
||
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
|
||
/// Windows none of the names [`windows_unsafe_name`] lists.
|
||
fn is_script_name(name: &str) -> bool {
|
||
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
|
||
}
|
||
|
||
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
|
||
fn is_bare_name(name: &str) -> bool {
|
||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||
}
|
||
|
||
/// Names that are not ordinary file names on Windows (elsewhere they are): with `:` (a drive or a
|
||
/// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device
|
||
/// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
|
||
/// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`).
|
||
fn windows_unsafe_name(name: &str) -> bool {
|
||
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase();
|
||
let port = |p: &str| {
|
||
let mut n = stem.strip_prefix(p).unwrap_or_default().chars();
|
||
matches!((n.next(), n.next()), (Some('0'..='9' | '¹' | '²' | '³'), None))
|
||
};
|
||
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") || port("COM") || port("LPT");
|
||
name.contains(':') || name.ends_with(['.', ' ']) || device
|
||
}
|
||
|
||
/// Installed scripts and panels, then the bundled ones not shadowed by an installed one, by name.
|
||
pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||
let mut v: Vec<ScriptEntry> = vec![];
|
||
if let Some(cfg) = &s.config {
|
||
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||
for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
|
||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||
}
|
||
}
|
||
}
|
||
for (name, panel, source, _) in BUNDLED {
|
||
if !v.iter().any(|e| e.name == *name && e.panel == *panel) {
|
||
v.push(ScriptEntry { name: name.to_string(), panel: *panel, source });
|
||
}
|
||
}
|
||
v.sort_by(|a, b| a.panel.cmp(&b.panel).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||
v
|
||
}
|
||
|
||
/// The code of an installed or bundled script / panel.
|
||
pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> Option<(bool, String)> {
|
||
if !is_script(name) || !is_script_name(name) {
|
||
return None;
|
||
}
|
||
if let Some(cfg) = &s.config {
|
||
for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||
if panel.is_some_and(|p| p != is_panel) {
|
||
continue;
|
||
}
|
||
if let Some(code) = cfg.read(&format!("{dir}/{name}")) {
|
||
return Some((is_panel, code));
|
||
}
|
||
}
|
||
}
|
||
BUNDLED.iter().find(|(n, p, ..)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, _, c)| (*p, c.to_string()))
|
||
}
|
||
|
||
fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
|
||
let c = if panel { "file.installScriptUIPanel" } else { "file.installScript" };
|
||
let path = str_p(p, "path").ok_or_else(|| bad(c, "missing `path` (a .jsx / .js script)"))?;
|
||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||
if !is_script(&name) {
|
||
return Err(bad(c, "scripts are .jsx or .js files (.jsxbin is not supported)"));
|
||
}
|
||
// A name it could not be run or uninstalled by (`\` is a file-name character on Unix).
|
||
if !is_script_name(&name) {
|
||
return Err(bad(c, format!("`{name}` is not a script file name")));
|
||
}
|
||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||
let code = String::from_utf8(bytes).map_err(|_| bad(c, "the script is not UTF-8 text"))?;
|
||
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?;
|
||
let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR };
|
||
cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?;
|
||
// After Effects asks for a restart; our menus pick it up at once.
|
||
s.toast(if panel {
|
||
format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu")
|
||
} else {
|
||
format!("Installed script {name}: find it in File ▸ Scripts")
|
||
});
|
||
Ok(json!({"name": name, "panel": panel}))
|
||
}
|
||
|
||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||
Ok(json!(scripts(s)))
|
||
}
|
||
|
||
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let c = "file.uninstallScript";
|
||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
|
||
if !is_script(name) || !is_script_name(name) {
|
||
return Err(bad(c, format!("`{name}` is not a script file name")));
|
||
}
|
||
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
|
||
let mut removed = false;
|
||
for dir in [SCRIPTS_DIR, PANELS_DIR] {
|
||
let key = format!("{dir}/{name}");
|
||
if cfg.read(&key).is_some() {
|
||
cfg.remove(&key).map_err(|e| EngineError::Other(e.to_string()))?;
|
||
removed = true;
|
||
}
|
||
}
|
||
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
|
||
}
|
||
|
||
/// Run an installed or bundled script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
|
||
/// this way gets `this` = a floating palette-like panel window.
|
||
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
|
||
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
|
||
if panel {
|
||
return open_panel(s, &json!({"name": name}));
|
||
}
|
||
super::file_more::run_js_named(s, &code, name)
|
||
}
|
||
|
||
/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
|
||
pub fn panel_wrapper(title: &str, code: &str) -> String {
|
||
// On one line, so line numbers in errors stay those of the file.
|
||
format!("(function () {{ {code}\n}}).call(__uiDockPanel({}));", Value::String(title.to_string()))
|
||
}
|
||
|
||
/// The ScriptUI panels open now (script names).
|
||
pub(crate) fn open_panel_names(s: &Session) -> Vec<String> {
|
||
s.script_ui.windows.iter().filter(|w| w.kind == scriptui::WindowKind::Panel).map(|w| w.script.clone()).collect()
|
||
}
|
||
|
||
/// The panels open when EffectCraft last quit (`{"open": [names]}`); a missing or unreadable
|
||
/// file is none.
|
||
fn remembered_panels(s: &Session) -> Vec<String> {
|
||
let text = s.config.as_ref().and_then(|c| c.read(OPEN_PANELS_FILE)).unwrap_or_default();
|
||
let doc: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
||
doc.get("open").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).filter(|n| is_script(n)).map(str::to_string).collect()
|
||
}
|
||
|
||
/// Change the remembered panels with `f` (written only when it changed). The list is kept so
|
||
/// the next launch reopens them, as After Effects reopens the panels of its workspace; quitting
|
||
/// leaves it as it is.
|
||
fn edit_remembered_panels(s: &Session, f: impl FnOnce(&mut Vec<String>)) {
|
||
let Some(cfg) = &s.config else { return };
|
||
let before = remembered_panels(s);
|
||
let mut list = before.clone();
|
||
f(&mut list);
|
||
if list != before
|
||
&& let Err(e) = cfg.write(OPEN_PANELS_FILE, &json!({"open": list}).to_string())
|
||
{
|
||
log::warn!("cannot remember the open ScriptUI panels: {e}");
|
||
}
|
||
}
|
||
|
||
/// Forget the panels of `before` (open before a script window event) that are closed now.
|
||
pub(crate) fn forget_closed_panels(s: &Session, before: &[String]) {
|
||
let now = open_panel_names(s);
|
||
let closed: Vec<&String> = before.iter().filter(|n| !now.contains(n)).collect();
|
||
if !closed.is_empty() {
|
||
edit_remembered_panels(s, |l| l.retain(|n| !closed.contains(&n)));
|
||
}
|
||
}
|
||
|
||
/// `window.restoreScriptPanels`: open the ScriptUI panels that were open when EffectCraft last
|
||
/// quit (frontends call it once at launch). Panels whose script is gone or fails are reported
|
||
/// and forgotten; the others still open.
|
||
fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let (mut opened, mut failed) = (vec![], vec![]);
|
||
for name in remembered_panels(s) {
|
||
let r = match script_code(s, &name, Some(true)) {
|
||
Some(_) => open_panel(s, &json!({"name": name})).map_err(|e| e.to_string()),
|
||
None => Err("no such ScriptUI panel".to_string()),
|
||
};
|
||
match r {
|
||
Ok(_) => opened.push(name),
|
||
Err(error) => {
|
||
edit_remembered_panels(s, |l| l.retain(|n| *n != name));
|
||
failed.push(json!({"name": name, "error": error}));
|
||
}
|
||
}
|
||
}
|
||
Ok(json!({"opened": opened, "failed": failed}))
|
||
}
|
||
|
||
/// Window ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
|
||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let c = "window.scriptPanel";
|
||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
|
||
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
|
||
if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
|
||
let id = w.id;
|
||
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
|
||
return Ok(json!({"window": id, "open": true}));
|
||
}
|
||
let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
|
||
let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
|
||
let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
|
||
if let Some(id) = id {
|
||
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
|
||
}
|
||
if id.is_some() {
|
||
edit_remembered_panels(s, |l| {
|
||
if !l.contains(&name) {
|
||
l.push(name.clone());
|
||
}
|
||
});
|
||
}
|
||
Ok(json!({"window": id, "result": out}))
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"file.installScript",
|
||
"Install Script File...",
|
||
["File", "Scripts"],
|
||
None,
|
||
"{path (.jsx / .js)} → copies it to the Scripts folder; it appears in File ▸ Scripts",
|
||
always,
|
||
|s, p| install(s, p, false)
|
||
),
|
||
cmd!(
|
||
"file.installScriptUIPanel",
|
||
"Install ScriptUI Panel...",
|
||
["File", "Scripts"],
|
||
None,
|
||
"{path (.jsx / .js)} → copies it to the ScriptUI Panels folder; it appears in the Window menu",
|
||
always,
|
||
|s, p| install(s, p, true)
|
||
),
|
||
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
|
||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
|
||
cmd!(
|
||
"window.scriptPanel",
|
||
"ScriptUI Panel",
|
||
[],
|
||
None,
|
||
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
|
||
always,
|
||
open_panel
|
||
),
|
||
cmd!(
|
||
"window.restoreScriptPanels",
|
||
"Restore ScriptUI Panels",
|
||
[],
|
||
None,
|
||
"{} → reopens the ScriptUI panels open when EffectCraft last quit (frontends call it at launch) → {opened: [name], failed: [{name, error}]}",
|
||
always,
|
||
restore_panels
|
||
),
|
||
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
|
||
cmd!(
|
||
"script.settings.save",
|
||
"Save Script Setting",
|
||
[],
|
||
None,
|
||
"{section, key, value (text)} → kept in the settings store (app.settings.saveSetting)",
|
||
always,
|
||
settings_save
|
||
),
|
||
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
|
||
crate::query!(
|
||
"scriptui.get",
|
||
"Script Window Controls",
|
||
"{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, draw (onDraw paint list), children…}}",
|
||
scriptui::get
|
||
),
|
||
cmd!(
|
||
"scriptui.click",
|
||
"Click Script Window Control",
|
||
[],
|
||
None,
|
||
"{window?: id | title, widget: id | \"#id\" | properties.name | text} (buttons, checkboxes, radio buttons, tabs)",
|
||
always,
|
||
scriptui::click
|
||
),
|
||
cmd!(
|
||
"scriptui.set",
|
||
"Set Script Window Control",
|
||
[],
|
||
None,
|
||
"{window?, widget, value: text | number | bool | item index | item text, changing?: bool (a live update: each keystroke / slider step; fires onChanging only)} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange",
|
||
always,
|
||
scriptui::set
|
||
),
|
||
cmd!(
|
||
"scriptui.close",
|
||
"Close Script Window",
|
||
[],
|
||
None,
|
||
"{window?, result? (a dialog's show() returns it; default 2 = Cancel)}",
|
||
always,
|
||
scriptui::close
|
||
),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{BUNDLED, PANELS_DIR, SCRIPTS_DIR, is_bare_name, is_script, is_script_name, script_code, windows_unsafe_name};
|
||
use crate::config::DirConfig;
|
||
use crate::{EngineError, Result, ScriptRequest, Session};
|
||
|
||
/// Stands in for the JavaScript runtime: reports the code it was asked to run.
|
||
fn echo(_: &mut Session, r: &ScriptRequest) -> Value {
|
||
json!({"ran": r.code})
|
||
}
|
||
|
||
/// A session whose settings store is a fresh `<root>/settings/` (with both Scripts folders, as
|
||
/// after installing a script and a panel), next to a script `<root>/outside.jsx`.
|
||
fn session_in_tmp(name: &str) -> (Session, PathBuf) {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
static N: AtomicU32 = AtomicU32::new(0);
|
||
let root = std::env::temp_dir().join(format!("ec-scripts-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
std::fs::create_dir_all(root.join("settings").join(PANELS_DIR)).unwrap();
|
||
std::fs::write(root.join("outside.jsx"), "outside").unwrap();
|
||
let s = Session { config: Some(Arc::new(DirConfig::new(root.join("settings")))), script: Some(echo), ..Default::default() };
|
||
(s, root)
|
||
}
|
||
|
||
fn bad_params(r: &Result<Value>) -> bool {
|
||
matches!(r, Err(EngineError::BadParams { .. }))
|
||
}
|
||
|
||
/// Paths, refused on every platform.
|
||
const PATHS: [&str; 10] = ["", ".", "..", "../x.jsx", "a/b.jsx", "/abs/x.jsx", "a\\b.jsx", "..\\x.jsx", "C:\\x.jsx", "a\0.jsx"];
|
||
/// Ordinary file names except on Windows.
|
||
const WINDOWS_ONLY: [&str; 8] = ["Step 1: Setup.jsx", "In:Out.jsx", "C:x.jsx", "x.jsx.", "x.jsx ", "Aux.Tools.jsx", "con.jsx", "COM1.jsx"];
|
||
|
||
#[test]
|
||
fn bare_names_are_one_path_component() {
|
||
for name in ["Rename Layers.jsx", "..jsx", "a..b.js", "Äpfel ✓.jsx"].into_iter().chain(WINDOWS_ONLY) {
|
||
assert!(is_bare_name(name), "{name:?}");
|
||
}
|
||
for name in PATHS {
|
||
assert!(!is_bare_name(name), "{name:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn windows_unsafe_names_are_drives_streams_trailing_dots_and_devices() {
|
||
let devices = ["CON", "Con.tar.jsx", "CON .jsx", "PRN.js", "aux.jsx", "NUL.jsx", "CONIN$.jsx", "conout$.js"];
|
||
let ports = ["COM0.jsx", "com9.js", "LPT0.jsx", "lpt9.jsx", "COM¹.jsx", "LPT³.js"];
|
||
for name in WINDOWS_ONLY.into_iter().chain(devices).chain(ports) {
|
||
assert!(windows_unsafe_name(name), "{name:?}");
|
||
}
|
||
let fine = ["console.jsx", "Concert.jsx", "NULL.jsx", "CONIN.jsx", "COM10.jsx", "LPT.jsx", "x .jsx", "..jsx"];
|
||
for name in BUNDLED.iter().map(|b| b.0).chain(fine) {
|
||
assert!(!windows_unsafe_name(name), "{name:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn script_names_are_bare_file_names() {
|
||
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
|
||
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
|
||
assert!(is_script(name) && is_script_name(name), "{name}");
|
||
}
|
||
for name in PATHS {
|
||
assert!(!is_script_name(name), "{name:?}");
|
||
}
|
||
for name in WINDOWS_ONLY {
|
||
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
|
||
}
|
||
// Install refuses a file name the script could not then be run or uninstalled by.
|
||
#[cfg(unix)]
|
||
{
|
||
let (mut s, root) = session_in_tmp("odd-name");
|
||
let odd = root.join("a\\b.jsx");
|
||
std::fs::write(&odd, "x").unwrap();
|
||
assert!(bad_params(&s.execute("file.installScript", json!({"path": odd.to_string_lossy()}))));
|
||
assert!(!root.join("settings").join(SCRIPTS_DIR).join("a\\b.jsx").exists());
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn uninstall_takes_a_script_name_not_a_path() {
|
||
let (mut s, root) = session_in_tmp("uninstall");
|
||
let (outside, prefs) = (root.join("outside.jsx"), root.join("settings").join("prefs.json"));
|
||
std::fs::write(&prefs, "{}").unwrap();
|
||
// From `Scripts/`, `../../outside.jsx` is the script next to the settings folder and
|
||
// `../prefs.json` a settings file; an absolute path is not a script name either.
|
||
for name in ["../../outside.jsx".to_string(), "../prefs.json".into(), outside.to_string_lossy().into_owned()] {
|
||
let r = s.execute("file.uninstallScript", json!({"name": name}));
|
||
assert!(outside.is_file() && prefs.is_file(), "`{name}` deleted a file outside the Scripts folders: {r:?}");
|
||
assert!(bad_params(&r), "`{name}`: {r:?}");
|
||
}
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn scripts_run_by_name_come_from_the_scripts_folders() {
|
||
let (mut s, root) = session_in_tmp("run");
|
||
// `Scripts/../../outside.jsx` and `Scripts/ScriptUI Panels/../../../outside.jsx` are the
|
||
// script next to the settings folder.
|
||
let r = s.execute("file.runScript", json!({"name": "../../outside.jsx"}));
|
||
assert!(bad_params(&r), "{r:?}");
|
||
let r = s.execute("window.scriptPanel", json!({"name": "../../../outside.jsx"}));
|
||
assert!(bad_params(&r), "{r:?}");
|
||
assert_eq!(script_code(&s, "../../outside.jsx", None), None);
|
||
assert_eq!(script_code(&s, "../../../outside.jsx", Some(true)), None);
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn only_scripts_are_read_or_removed() {
|
||
// A settings file in the Scripts folder that is not a script (.jsx / .js).
|
||
let (mut s, root) = session_in_tmp("not-a-script");
|
||
let notes = root.join("settings").join(SCRIPTS_DIR).join("notes.txt");
|
||
std::fs::write(¬es, "notes").unwrap();
|
||
assert_eq!(script_code(&s, "notes.txt", None), None);
|
||
let r = s.execute("file.uninstallScript", json!({"name": "notes.txt"}));
|
||
assert!(notes.is_file() && bad_params(&r), "{r:?}");
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
/// On Unix `\` is a file-name character: a script dropped into the folder under such a name is
|
||
/// not offered, as it could not be run or uninstalled. Names only Windows refuses stay.
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn listing_skips_names_run_and_uninstall_refuse() {
|
||
let (mut s, root) = session_in_tmp("odd-names");
|
||
let scripts = root.join("settings").join(SCRIPTS_DIR);
|
||
std::fs::write(scripts.join("a\\b.jsx"), "x").unwrap();
|
||
std::fs::write(scripts.join("In:Out.jsx"), "in-out").unwrap();
|
||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||
let listed = |n: &str| list.as_array().unwrap().iter().any(|e| e["name"] == n);
|
||
assert!(!listed("a\\b.jsx") && listed("In:Out.jsx"), "{list}");
|
||
assert_eq!(script_code(&s, "a\\b.jsx", None), None);
|
||
assert_eq!(script_code(&s, "In:Out.jsx", None), Some((false, "in-out".to_string())));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn install_run_and_uninstall_by_name() {
|
||
let (mut s, root) = session_in_tmp("install");
|
||
let file = root.join("Hello.jsx");
|
||
std::fs::write(&file, "hello").unwrap();
|
||
s.execute("file.installScript", json!({"path": file.to_string_lossy()})).unwrap();
|
||
s.execute("file.installScriptUIPanel", json!({"path": file.to_string_lossy()})).unwrap();
|
||
let installed = [root.join("settings").join(SCRIPTS_DIR).join("Hello.jsx"), root.join("settings").join(PANELS_DIR).join("Hello.jsx")];
|
||
assert!(installed.iter().all(|p| p.is_file()));
|
||
assert_eq!(s.execute("file.runScript", json!({"name": "Hello.jsx"})).unwrap()["ran"], "hello");
|
||
let r = s.execute("window.scriptPanel", json!({"name": "Hello.jsx"})).unwrap();
|
||
assert!(r["result"]["ran"].as_str().is_some_and(|c| c.contains("hello")), "{r}");
|
||
// The bundled samples still run by name.
|
||
let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
|
||
assert_eq!(s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap()["ran"], sample);
|
||
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
|
||
assert!(installed.iter().all(|p| !p.exists()));
|
||
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed");
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
}
|