From 2c0d09e079ebb3e900b1aa989093fde987bc6ff1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:35:41 -0800 Subject: [PATCH] =?UTF-8?q?Scripts:=20File=20=E2=80=BA=20Scripts=20?= =?UTF-8?q?=E2=80=BA=20Other=20Script=E2=80=A6=20runs=20JavaScript=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Scripts › Other Script… picks a .js or .jsx file, shows a warning that can be turned off in Preferences › General, and runs the script against the open documents through the `vc` object. The new `script.run` and `script.stop` commands run and stop a script over MCP. - A run is one transaction. Each document it changed gets one undo step named Run Script. An uncaught error, a limit or a stop puts every document, the paint, the clipboard, menu state and preferences back. - A command that fails inside a run, or whose result is too large to reach the script, is undone and throws an error the script can catch. - Files a command wrote stay on disk and the report names them, also when the run is stopped from the quit question. - While a script runs, other commands that change anything fail with a busy error; MCP reports it as JSON-RPC error -32003. - In the app the run advances one slice per frame with a Stop button in the status bar. A run that adds a great many objects asks whether to continue; Stop and Escape end it. - Commands that change state outside the documents, such as prefs.*, plugin.* and file.place.queue, are refused to scripts. Docs: docs/mcp.md, docs/gaps.md, docs/roadmap.md, docs/target-app-parity.md. --- apps/vectorcraft-web/Cargo.toml | 2 +- apps/vectorcraft-web/src/web.rs | 33 +- crates/doc/src/document.rs | 5 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/cmd/recovery.rs | 34 +- crates/engine/src/cmd/scripts.rs | 134 ++- crates/engine/src/command_sets.rs | 1 + crates/engine/src/error.rs | 6 + crates/engine/src/file_access.rs | 35 +- crates/engine/src/lib.rs | 18 +- crates/engine/src/link_watch.rs | 13 +- crates/engine/src/prefs.rs | 3 + crates/engine/src/script/mod.rs | 1091 ++++++++++++++++++++++ crates/engine/src/session/documents.rs | 7 + crates/engine/src/session/edit.rs | 9 + crates/engine/src/session/execute.rs | 8 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/linkwatch.rs | 20 + crates/engine/src/tests/script_run.rs | 728 +++++++++++++++ crates/engine/src/tooling.rs | 9 + crates/js/src/builtins.rs | 2 +- crates/js/src/heap.rs | 4 + crates/js/src/host.rs | 351 +++++++ crates/js/src/host_tests.rs | 277 ++++++ crates/js/src/interp.rs | 55 +- crates/js/src/lib.rs | 6 +- crates/js/src/realm.rs | 130 ++- crates/mcp/src/server.rs | 9 +- crates/mcp/src/tests/concurrency.rs | 17 + crates/mcp/src/tools.rs | 5 + crates/ui-egui/src/app.rs | 32 +- crates/ui-egui/src/chrome/status_bar.rs | 18 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/dialogs.rs | 8 + crates/ui-egui/src/dialogs/script_run.rs | 147 +++ crates/ui-egui/src/i18n/bg.tsv | 9 + crates/ui-egui/src/i18n/bs.tsv | 9 + crates/ui-egui/src/i18n/ca.tsv | 21 + crates/ui-egui/src/i18n/cs.tsv | 21 + crates/ui-egui/src/i18n/da.tsv | 9 + crates/ui-egui/src/i18n/de.tsv | 21 + crates/ui-egui/src/i18n/el.tsv | 21 + crates/ui-egui/src/i18n/es.tsv | 21 + crates/ui-egui/src/i18n/fi.tsv | 21 + crates/ui-egui/src/i18n/fr.tsv | 21 + crates/ui-egui/src/i18n/hi.tsv | 21 + crates/ui-egui/src/i18n/hu.tsv | 21 + crates/ui-egui/src/i18n/id.tsv | 9 + crates/ui-egui/src/i18n/it.tsv | 21 + crates/ui-egui/src/i18n/ja.tsv | 21 + crates/ui-egui/src/i18n/ka.tsv | 21 + crates/ui-egui/src/i18n/ko.tsv | 21 + crates/ui-egui/src/i18n/nl.tsv | 9 + crates/ui-egui/src/i18n/pl.tsv | 21 + crates/ui-egui/src/i18n/pt-br.tsv | 21 + crates/ui-egui/src/i18n/ru.tsv | 21 + crates/ui-egui/src/i18n/sv.tsv | 21 + crates/ui-egui/src/i18n/tr.tsv | 21 + crates/ui-egui/src/i18n/uk.tsv | 21 + crates/ui-egui/src/i18n/vi.tsv | 21 + crates/ui-egui/src/i18n/zh-hans.tsv | 21 + crates/ui-egui/src/i18n/zh-hant.tsv | 21 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/tree.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/picks.rs | 1 + crates/ui-egui/src/script_runs.rs | 44 + crates/ui-egui/src/services.rs | 5 + crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/script_check.rs | 2 +- crates/ui-egui/src/tests/script_run.rs | 283 ++++++ docs/gaps.md | 5 +- docs/mcp.md | 68 ++ docs/roadmap.md | 2 +- docs/target-app-parity.md | 1 + 77 files changed, 4093 insertions(+), 58 deletions(-) create mode 100644 crates/engine/src/script/mod.rs create mode 100644 crates/engine/src/tests/script_run.rs create mode 100644 crates/js/src/host.rs create mode 100644 crates/js/src/host_tests.rs create mode 100644 crates/ui-egui/src/dialogs/script_run.rs create mode 100644 crates/ui-egui/src/script_runs.rs create mode 100644 crates/ui-egui/src/tests/script_run.rs diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 6e4dbd7f0..eb35a110c 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -26,7 +26,7 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } -rfd = { workspace = true } +rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen = "0.2" serde_json = { workspace = true } wasm-bindgen-futures = "0.4" diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 60682d698..b4ca434d9 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -9,6 +9,9 @@ use vectorcraft_engine::Session; use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address}; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore}; +use vectorcraft_engine::cmd::scripts::RUN_EXTS; +use vectorcraft_engine::script::MAX_SOURCE_BYTES; +use vectorcraft_ui_egui::dialogs::script_run::ScriptInbox; use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::i18n; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; @@ -47,6 +50,7 @@ struct Host { runner: eframe::WebRunner, inbox: Inbox, place_inbox: PlaceInbox, + script_inbox: ScriptInbox, drag: DragPos, pasted: Rc>, /// Pen and finger input read from the page's Pointer Events (`crate::pencil`). @@ -75,6 +79,7 @@ pub fn start() { runner: eframe::WebRunner::new(), inbox: Arc::default(), place_inbox: Arc::default(), + script_inbox: Arc::default(), drag: DragPos::default(), pasted: Rc::default(), pointers: SharedPointers::default(), @@ -120,7 +125,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { // The old services asked the old context for frames. @@ -425,12 +431,35 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option, stylus: StylusFeed) -> Services { +fn services( + inbox: Inbox, + place_inbox: PlaceInbox, + script_inbox: ScriptInbox, + ctx: egui::Context, + locks: Option, + stylus: StylusFeed, +) -> Services { let open_inbox = inbox.clone(); let picked = place_inbox.clone(); let place_ctx = ctx.clone(); let screen_color = eye_dropper(ctx.clone()); + let (script_picked, script_ctx) = (script_inbox.clone(), ctx.clone()); Services { + // File › Scripts › Other Script…: the picked file goes to the warning before it runs. + script_async: Some(Box::new(move || { + let inbox = script_picked.clone(); + let ctx = script_ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let filter = vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", "Scripts"); + let Some(file) = rfd::AsyncFileDialog::new().add_filter(filter, RUN_EXTS).pick_file().await else { + return; + }; + let bytes = if file.inner().size() > MAX_SOURCE_BYTES as f64 { None } else { Some(file.read().await) }; + inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + ctx.request_repaint(); + }); + })), + script_inbox: Some(script_inbox), // File → Place…: the picked files go to the Place dialog. place_async: Some(Box::new(move || { let inbox = picked.clone(); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index ebd27fd87..2a2f8a8d7 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -276,6 +276,11 @@ impl Document { self.next_id } + /// Makes the next id at least `id`, so ids below it are not given out again. + pub fn raise_next_id(&mut self, id: u64) { + self.next_id = self.next_id.max(id); + } + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). pub fn fix_next_id(&mut self) { // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 3394585bb..e974e5542 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -40,6 +40,7 @@ vectorcraft-metafile = { workspace = true } vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } +vectorcraft-js = { workspace = true } image = { workspace = true } jxl-oxide = { workspace = true } jxl-color = { workspace = true } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f3cf6416f..f85c74198 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -140,6 +140,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("disableAutoAddDelete", "General", "Options", "Disable Auto Add/Delete", bool), p!("usePreciseCursors", "General", "Options", "Use Precise Cursors", bool), p!("showToolTips", "General", "Options", "Show Tool Tips", bool), + p!("showExternalScriptWarning", "General", "Options", "Show External Script Warning", bool), p!("antiAliasedArtwork", "General", "Options", "Anti-aliased Artwork", bool), p!("selectSameTintPercent", "General", "Options", "Select Same Tint %", bool), p!("showHomeScreen", "General", "Options", "Show The Home Screen When No Documents Are Open", bool), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index a06597ac8..4cf070c6a 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -766,11 +766,14 @@ pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> { return Some("unchanged since its last recovery copy"); } if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS { - return Some("complex document (Data Recovery is off for complex documents)"); + return Some(COMPLEX_SKIP); } None } +/// Why a complex document with unsaved changes gets no copy. +const COMPLEX_SKIP: &str = "complex document (Data Recovery is off for complex documents)"; + /// A file name part from a title: letters, digits, `-`, `_` and spaces (others as `-`). fn safe_stem(title: &str) -> String { let stem: String = fileio::file_stem(title).chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ') { c } else { '-' }).collect(); @@ -866,8 +869,35 @@ fn remove_copy(s: &Session, file: &str) { } } -/// Document `uid` was saved, reverted or closed: its recovery copy goes. +/// Closed document `st`'s recovery copy goes. +pub(crate) fn forget_closed(s: &Session, st: &crate::DocState) { + if let Some(copy) = &st.recovery { + remove_copy(s, ©.file); + } +} + +/// Writes a copy of every document that needs one now. An error when a document with unsaved +/// changes would be left without a current copy. +pub(crate) fn save_copies(s: &mut Session) -> Result<()> { + let store = need_store(s, "script.run")?; + let (jobs, _) = jobs(s, &store)?; + let uncopied = s.documents().iter().any(|st| skip_reason(s, st) == Some(COMPLEX_SKIP)); + if uncopied { + return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into())); + } + for j in jobs { + j.write(store.as_ref()).map_err(EngineError::Other)?; + j.finish(s); + } + Ok(()) +} + +/// Document `uid` was saved, reverted or closed: its recovery copy goes. While a script runs the +/// run's commit settles that. pub fn forget(s: &mut Session, uid: u64) { + if s.script_in_run { + return; + } let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return }; remove_copy(s, ©.file); } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 45f267048..692de92f3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -11,21 +11,135 @@ use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report}; /// The ids a reply can hold, for the windows that show it. pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS}; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param}; +use crate::script; /// The extensions of the scripts File › Open shows a check for. pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"]; +/// The extensions of the scripts `script.run` takes as a file, in any case. +pub const RUN_EXTS: &[&str] = &["js", "jsx"]; + +/// Does `path` name a file `script.run` takes? +pub fn runs(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| RUN_EXTS.iter().any(|x| e.eq_ignore_ascii_case(x))) +} + pub fn specs() -> Vec { - vec![cmd!( - query "script.check", - "Check Script", - [], - None, - "{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)", - always, - check - )] + vec![ + cmd!( + query "script.run", + "Run Script", + [], + None, + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: {} (no prompts exist yet: only an empty object is taken), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report; vc.args is args; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport", + always, + run + ), + cmd!( + query "script.stop", + "Stop Script", + [], + None, + "{} stop the running script, which puts every document back as it was before the run; asking again while it stops is harmless → {stopped: whether a script was running}", + always, + stop + ), + cmd!( + query "script.check", + "Check Script", + [], + None, + "{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)", + always, + check + ), + ] +} + +/// A refusal of `script.run`'s parameters, which only a program's own call can give. +fn param(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: "script.run".into(), msg: msg.into() } +} + +/// `maxFuel`: a whole number from 1 to [`script::MAX_FUEL`], or the default when omitted. +fn max_fuel(p: &Value) -> Result { + let Some(v) = p.get("maxFuel") else { return Ok(script::DEFAULT_FUEL) }; + v.as_u64() + .filter(|n| (1..=script::MAX_FUEL).contains(n)) + .ok_or_else(|| param(format!("`maxFuel` must be a whole number from 1 to {}", script::MAX_FUEL))) +} + +/// The text of a script read as bytes: a byte order mark is left out, and compiled scripts and +/// bytes that are not UTF-8 are refused. +fn script_text(bytes: &[u8]) -> Result { + if bytes.starts_with(b"@JSXBIN@") { + return Err(EngineError::Other(COMPILED.to_string())); + } + let text = std::str::from_utf8(bytes).map_err(|_| EngineError::Other(NOT_TEXT.to_string()))?; + Ok(text.strip_prefix('\u{feff}').unwrap_or(text).to_string()) +} + +const COMPILED: &str = "this is a compiled script, which cannot be run; use its source text"; +const NOT_TEXT: &str = "the script is not UTF-8 text"; +/// The error for a script over [`script::MAX_SOURCE_BYTES`]. +pub const TOO_LARGE: &str = "the script is too large to run"; + +fn run(s: &mut Session, p: &Value) -> Result { + let optional_str = |key: &str| match p.get(key) { + None => Ok(None), + Some(Value::String(t)) => Ok(Some(t.as_str())), + Some(_) => Err(param(format!("`{key}` must be a string"))), + }; + let (path, source, given_name) = (optional_str("path")?, optional_str("source")?, optional_str("name")?); + let args = match p.get("args") { + None => serde_json::Map::new(), + Some(Value::Object(o)) => o.clone(), + Some(_) => return Err(param("`args` must be an object")), + }; + if serde_json::to_vec(&args).map_or(true, |b| b.len() > script::MAX_ARGS_BYTES) { + return Err(param("`args` is larger than 1 MiB")); + } + match p.get("answers") { + None => {} + Some(Value::Object(o)) if o.is_empty() => {} + Some(_) => return Err(param("`answers` must be an empty object: scripts cannot ask questions yet")), + } + let fuel = max_fuel(p)?; + let text = match (path, source) { + (Some(""), None) => return Err(param("`path` is empty")), + (Some(path), None) => { + if path.to_ascii_lowercase().ends_with(".jsxbin") { + return Err(EngineError::Other(COMPILED.to_string())); + } + if !runs(path) { + return Err(param("`path` must name a .js or .jsx file")); + } + // One byte more than a script may have shows that the file is too large. + let bytes = super::fileio::read_file_head(path, script::MAX_SOURCE_BYTES + 1)?; + if bytes.len() > script::MAX_SOURCE_BYTES { + return Err(EngineError::Other(TOO_LARGE.to_string())); + } + script_text(&bytes)? + } + (None, Some(source)) => { + if source.len() > script::MAX_SOURCE_BYTES { + return Err(EngineError::Other(TOO_LARGE.to_string())); + } + script_text(source.as_bytes())? + } + _ => return Err(param("give exactly one of `path` and `source`")), + }; + let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); + s.script_start(script::Request { name: name.clone(), source: text, args, fuel })?; + if s.script_frames { + return Ok(json!({"running": true, "name": name})); + } + s.script_run_to_end() +} + +fn stop(s: &mut Session, _: &Value) -> Result { + Ok(json!({"stopped": s.script_request_stop()})) } /// The folder a script is in, which its included files are read from. diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 38ce14cab..77068d125 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -83,6 +83,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { /// Instances and Lock Guides change the selection, and the proof views, the preferences and added /// fonts give the documents new revisions (they draw again, and type lays out again). const WAIT_FOR_TOOL_EDIT: &[&str] = &[ + "script.run", "command.batch", "document.find", "symbol.selectInstances", diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 8d939db77..d32fda613 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,5 +1,8 @@ use super::{NodeId, cancel}; +/// The text of [`EngineError::Busy`], which ends every error a frontend reports for it. +pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish"; + #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] @@ -20,6 +23,9 @@ pub enum EngineError { /// The caller asked for the command to stop ([`cancel`]). It changed nothing. #[error("cancelled; nothing was changed")] Cancelled, + /// A running script holds the documents until it ends. Nothing was changed. + #[error("{}", SCRIPT_BUSY)] + Busy, /// A bug: the command panicked. The document was rolled back to its state before the command. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 0565a9115..cdea7ac5b 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -208,7 +208,40 @@ pub fn check_read(path: &str) -> Result<(), String> { /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { - current().map_or(Ok(()), |r| r.check(path, Access::Write)) + current().map_or(Ok(()), |r| r.check(path, Access::Write))?; + WRITES.with(|w| { + if let Some(paths) = w.borrow_mut().as_mut() + && !paths.iter().any(|(p, _)| p == path) + { + paths.push((path.to_string(), stamp(path))); + } + }); + Ok(()) +} + +/// A file's modification time and length; `None` when there is no file at `path`. +type Stamp = Option<(Option, u64)>; + +fn stamp(path: &str) -> Stamp { + std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file).map(|m| (m.modified().ok(), m.len())) +} + +thread_local! { + /// The paths [`check_write`] allowed on this thread while they are being recorded, each with + /// its file's stamp at that moment. + static WRITES: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Starts recording the paths [`check_write`] allows on this thread. +pub(crate) fn start_recording_writes() { + WRITES.with(|w| *w.borrow_mut() = Some(Vec::new())); +} + +/// Ends recording → the recorded paths whose file exists now: after a command that `succeeded`, +/// all of them; after one that failed, those made or changed since they were allowed. +pub(crate) fn finish_recording_writes(succeeded: bool) -> Vec { + let recorded = WRITES.with(|w| w.borrow_mut().take()).unwrap_or_default(); + recorded.into_iter().filter(|(path, before)| stamp(path).is_some_and(|now| succeeded || Some(now) != *before)).map(|(path, _)| path).collect() } /// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ffcd43019..bf8b5162d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -242,6 +242,21 @@ pub struct Session { pub(crate) screens_hand_off: Option>, /// Where Data Recovery keeps its copies ([`cmd::recovery`]). pub recovery: cmd::recovery::Recovery, + /// The running script's job, while there is one: the mutation lease ([`script`]). + pub(crate) script_key: Option, + /// Stops the running script at its next instruction; the same flag for every run. + pub(crate) script_stop: vectorcraft_js::Interrupt, + /// The report of the run that ended last. + pub(crate) script_report: Option, + /// `script.run` starts the job and returns, and the frontend advances it once per frame + /// ([`Session::script_pump`]). Off, `script.run` advances it to its end. + pub script_frames: bool, + /// Each open document's highest next id a run has seen, by [`DocState::uid`]. + pub(crate) script_high_water: std::collections::HashMap, + /// While a slice of a script runs in this session. + pub(crate) script_in_run: bool, + /// Write recovery copies of the unsaved documents before a run, and don't start without them. + pub script_copy_first: bool, /// While the active tool's actions run: their commands aren't "a command from outside the /// tool" ([`Session::after_command`]). pub(crate) in_tool_actions: bool, @@ -328,6 +343,7 @@ mod history; mod interaction; mod prefs; mod sanity; +pub mod script; mod session; #[cfg(test)] @@ -336,7 +352,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS; use command_sets::given_ids; pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; -pub use error::{EngineError, Result}; +pub use error::{EngineError, Result, SCRIPT_BUSY}; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; pub(crate) use interaction::PreviewCopies; pub use interaction::{Interaction, LastTransform, UndoGroup, UndoScope}; diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 6cdf5c359..747ce9154 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -84,9 +84,9 @@ pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result { impl Session { /// Start stamping the active document's linked files on a worker thread, unless a look is - /// still running (or the build has no files to look at: the web app). + /// still running, a script is running (or the build has no files to look at: the web app). pub fn start_link_scan(&mut self) { - if self.link_scan.is_some() || cfg!(target_arch = "wasm32") { + if self.link_scan.is_some() || self.script_key.is_some() || cfg!(target_arch = "wasm32") { return; } let Some(st) = self.active() else { return }; @@ -115,10 +115,13 @@ impl Session { } } - /// Apply a finished look (`None` while there is none, or it is still running): see - /// [`Session::apply_link_stamps`]. A look whose worker ended without stamps is cleared, also - /// with `None`, so the next one can start. + /// Apply a finished look (`None` while there is none, it is still running, or a script is + /// running): see [`Session::apply_link_stamps`]. A look whose worker ended without stamps is + /// cleared, also with `None`, so the next one can start. pub fn poll_link_scan(&mut self) -> Option> { + if self.script_key.is_some() { + return None; + } self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst))); let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?; self.link_scan = None; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d3b034526..779f4b8da 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -31,6 +31,8 @@ pub struct Prefs { pub disable_auto_add_delete: bool, pub use_precise_cursors: bool, pub show_tool_tips: bool, + /// Other Script… warns before it runs a chosen script. + pub show_external_script_warning: bool, pub anti_aliased_artwork: bool, pub select_same_tint_percent: bool, pub show_home_screen: bool, @@ -364,6 +366,7 @@ impl Default for Prefs { disable_auto_add_delete: false, use_precise_cursors: false, show_tool_tips: true, + show_external_script_warning: true, anti_aliased_artwork: true, select_same_tint_percent: false, show_home_screen: true, diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs new file mode 100644 index 000000000..4b5380319 --- /dev/null +++ b/crates/engine/src/script/mod.rs @@ -0,0 +1,1091 @@ +//! Running a script: `script.run`, `script.stop` and the job behind them. +//! +//! A run works on its own copy of the open documents and the session state commands change (the +//! working copy). Between two slices of a run, the session holds the state as it was before the +//! run: what is drawn, what a read-only command returns and what Data Recovery copies never show a +//! script's unfinished work. Each slice swaps the working copy in, advances the interpreter by one +//! measured slice of fuel and swaps it out. A commit makes the working copy the session's state, +//! with one undo step for each document whose art changed. A rollback drops the working copy. +//! +//! While a job exists, [`Session::execute`] refuses any command that can edit, select, save or give +//! a document a new revision, and the tool events are refused the same way (the mutation lease). +//! The script's own commands run through [`Session::execute`] inside a slice, each with its own +//! parameter checks, enabled rule, undo history and journal entry, and a command that fails leaves +//! no trace of itself. Inside a run, `edit.undo` reaches only steps the script made. +//! +//! Files a script's commands saved or exported before it failed stay written, and the failure names +//! them. Ids given out in a run are never given out again for a document, also after an in-run +//! undo or a rollback. +//! +//! The values here are VectorCraft's own limits: [`MAX_SOURCE_BYTES`], [`DEFAULT_FUEL`], +//! [`MAX_FUEL`], [`SLICE_FUEL`], [`HEAP_BYTES`], [`MAX_HOST_CALLS`], [`GROWTH_OBJECTS`], +//! [`GROWTH_BYTES`], [`MAX_CONTINUES`], [`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], [`MAX_ARGS_BYTES`], +//! and the 64 levels, 16 MiB and 1,048,576 entries of [`vectorcraft_js::DataLimits`]'s default. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; + +use serde_json::{Map, Value, json}; +use vectorcraft_js::{ + Data, DataLimits, HostError, HostFunction, HostItem, Interrupt, LimitKind, Limits, Options, Progress, Realm, RunError, RunErrorKind, +}; + +use crate::{ + Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit, +}; + +/// The most bytes of source a run takes, as a file or as `source`. +pub const MAX_SOURCE_BYTES: usize = 8 << 20; +/// The instructions a run may execute when `maxFuel` is left out. +pub const DEFAULT_FUEL: u64 = 1_000_000_000; +/// The largest `maxFuel`. +pub const MAX_FUEL: u64 = 100_000_000_000; +/// The instructions one slice of a run executes before the interface draws again. +pub const SLICE_FUEL: u64 = 500_000; +/// The heap one run may use. +#[cfg(not(target_arch = "wasm32"))] +pub const HEAP_BYTES: usize = 256 << 20; +/// The heap one run may use in a wasm build. +#[cfg(target_arch = "wasm32")] +pub const HEAP_BYTES: usize = 128 << 20; +/// The most `vc.run` calls and getter reads of one run. +pub const MAX_HOST_CALLS: u64 = 2_000_000; +/// The objects a run may make before it is asked to continue or stop. +pub const GROWTH_OBJECTS: u64 = 100_000; +/// The bytes of new document data a run may keep before it is asked to continue or stop. +pub const GROWTH_BYTES: u64 = 512 << 20; +/// How many times a person may let a run go past its growth allowance; each time adds one more. +pub const MAX_CONTINUES: u32 = 8; +/// The most `vc.log` entries of one run. +pub const MAX_LOG_LINES: usize = 10_000; +/// The most bytes of `vc.log` entries of one run. +pub const MAX_LOG_BYTES: usize = 4 << 20; +/// The most bytes of `args` given to one run, as JSON. +pub const MAX_ARGS_BYTES: usize = 1 << 20; +/// The `vc.run` calls one slice makes before the interface draws again. +const SLICE_HOST_CALLS: u32 = 256; +/// The bytes that may cross between a script and the host in one run. +const MAX_BRIDGE_BYTES: u64 = 4 << 30; + +/// The version `vc.api.version` gives. +pub const API_VERSION: &str = "1.0"; +/// The features `vc.api.has` knows. +pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args"]; + +/// What the step of a run's one undo step is called. +pub const STEP_LABEL: &str = "Run Script"; + +/// `id`, as `vc.run` is given it, is one a script may not run: a command that would leave the run's +/// transaction or change something outside the documents that a rollback could not put back. +pub fn refused_to_scripts(id: &str) -> bool { + const PREFIXES: &[&str] = &[ + "script.", + "plugin.", + "prefs.", + "actionSet.", + "shortcutSet.", + "help.", + "print.", + "library.", + "importMatches.", + "edit.undoGroup.", + "file.recovery.", + "stroke.widthProfile.", + ]; + const IDS: &[&str] = &[ + "text.addFontFiles", + "text.findFontFiles", + "text.rescanFonts", + "links.findFiles", + "file.place.queue", + "color.loadProfile", + "color.removeProfile", + "file.print", + "view.proofSetup", + "view.proofColors", + "view.overprintPreview", + "view.separationsPreview", + ]; + let saved_preset = (id.contains("resets.") || id.contains(".preset.")) && !id.ends_with(".list") && !id.ends_with(".apply"); + id != "script.check" && (saved_preset || PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id)) +} + +/// The error for a script's command that [`refused_to_scripts`] names. +pub(crate) fn refused_text(id: &str) -> String { + format!("a script cannot run `{id}`") +} + +/// A request to run a script. +pub(crate) struct Request { + pub name: String, + pub source: String, + pub args: Map, + pub fuel: u64, +} + +thread_local! { + /// The running jobs of this thread's sessions. A job is held here, and not in its session, + /// because it holds the interpreter, which stays on the thread that started it. + static JOBS: RefCell> = RefCell::new(HashMap::new()); +} + +static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// A session's hold on its running job: while one exists the lease is held. Dropping it ends the job. +#[derive(Debug)] +pub struct JobKey(u64); + +impl Drop for JobKey { + fn drop(&mut self) { + let _ = JOBS.try_with(|jobs| { + if let Ok(mut jobs) = jobs.try_borrow_mut() { + jobs.remove(&self.0); + } + }); + } +} + +fn with_job(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { + JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.0).map(f)) +} + +/// The state a script changes that a rollback puts back. +struct World { + docs: Vec, + active: Option, + paint: PaintDefaults, + fill_active: bool, + draw_mode: DrawMode, + draw_inside: Option, + clipboard: Clipboard, + untitled_counter: u32, + menu: cmd::menucmds::MenuState, + last_solid: crate::Color, + last_gradient: crate::GradientPaint, + recent_colors: Vec, + pending_paint: Option, + choices: Choices, + journal: Journal, + /// The preferences when the run started. + start_prefs: crate::Prefs, +} + +/// The choices outside the documents that a script's commands can change: the preferences, the +/// global edit options, the recent URLs, the chosen gradient stop, freeform point and appearance +/// item, the Eyedropper's sample and the envelope defaults. +#[derive(Clone)] +struct Choices { + prefs: crate::Prefs, + eyedropper_sample: Option>, + global_edit_options: cmd::globaledit::Options, + recent_urls: Vec, + gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, + freeform_point: Option<(usize, cmd::gradient::StopOwner)>, + active_appearance_item: Option, + envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, +} + +impl Choices { + fn of(s: &Session) -> Self { + Self { + prefs: s.prefs.clone(), + eyedropper_sample: s.eyedropper_sample.clone(), + global_edit_options: s.global_edit_options.clone(), + recent_urls: s.recent_urls.clone(), + gradient_stop: s.gradient_stop, + freeform_point: s.freeform_point, + active_appearance_item: s.active_appearance_item.clone(), + envelope_defaults: s.envelope_defaults, + } + } + + fn put(self, s: &mut Session) { + s.prefs = self.prefs; + s.eyedropper_sample = self.eyedropper_sample; + s.global_edit_options = self.global_edit_options; + s.recent_urls = self.recent_urls; + s.gradient_stop = self.gradient_stop; + s.freeform_point = self.freeform_point; + s.active_appearance_item = self.active_appearance_item; + s.envelope_defaults = self.envelope_defaults; + } + + fn swap(&mut self, s: &mut Session) { + use std::mem::swap; + swap(&mut self.prefs, &mut s.prefs); + swap(&mut self.eyedropper_sample, &mut s.eyedropper_sample); + swap(&mut self.global_edit_options, &mut s.global_edit_options); + swap(&mut self.recent_urls, &mut s.recent_urls); + swap(&mut self.gradient_stop, &mut s.gradient_stop); + swap(&mut self.freeform_point, &mut s.freeform_point); + swap(&mut self.active_appearance_item, &mut s.active_appearance_item); + swap(&mut self.envelope_defaults, &mut s.envelope_defaults); + } +} + +/// The preferences after a run: `current`, the session's, which the app may have changed while the +/// run went on, with each preference the run changed from `start` set to the run's value. +fn merged_prefs(start: &crate::Prefs, run: &crate::Prefs, current: &crate::Prefs) -> crate::Prefs { + let (Ok(Value::Object(start)), Ok(Value::Object(run)), Ok(Value::Object(mut out))) = + (serde_json::to_value(start), serde_json::to_value(run), serde_json::to_value(current)) + else { + return current.clone(); + }; + for (key, value) in run { + if start.get(&key) != Some(&value) { + out.insert(key, value); + } + } + serde_json::from_value(Value::Object(out)).unwrap_or_else(|_| current.clone()) +} + +impl World { + /// A working copy of the session's state: its documents, each with an undo history of its own + /// that starts empty, and an empty journal. + fn working(s: &Session) -> Self { + let mut docs = s.docs.clone(); + for st in &mut docs { + st.history.undo.clear(); + st.history.redo.clear(); + } + Self { + docs, + active: s.active, + paint: s.paint.clone(), + fill_active: s.fill_active, + draw_mode: s.draw_mode, + draw_inside: s.draw_inside, + clipboard: s.clipboard.clone(), + untitled_counter: s.untitled_counter, + menu: s.menu.clone(), + last_solid: s.last_solid, + last_gradient: s.last_gradient.clone(), + recent_colors: s.recent_colors.clone(), + pending_paint: s.pending_paint.clone(), + choices: Choices::of(s), + journal: Journal::default(), + start_prefs: s.prefs.clone(), + } + } + + /// Exchanges this state with the session's. + fn swap(&mut self, s: &mut Session) { + use std::mem::swap; + swap(&mut self.docs, &mut s.docs); + swap(&mut self.active, &mut s.active); + swap(&mut self.paint, &mut s.paint); + swap(&mut self.fill_active, &mut s.fill_active); + swap(&mut self.draw_mode, &mut s.draw_mode); + swap(&mut self.draw_inside, &mut s.draw_inside); + swap(&mut self.clipboard, &mut s.clipboard); + swap(&mut self.untitled_counter, &mut s.untitled_counter); + swap(&mut self.menu, &mut s.menu); + swap(&mut self.last_solid, &mut s.last_solid); + swap(&mut self.last_gradient, &mut s.last_gradient); + swap(&mut self.recent_colors, &mut s.recent_colors); + swap(&mut self.pending_paint, &mut s.pending_paint); + self.choices.swap(s); + swap(&mut self.journal, &mut s.journal); + } +} + +/// The commands that can change the clipboard and still fail, and that do not put it back +/// themselves. The clipboard can hold a great deal, so a savepoint copies it only for these. +const CAN_FAIL_AFTER_COPYING: &[&str] = &["action.batch", "edit.cut"]; + +/// What one command may need to put back when it fails inside a run: the state of [`World`] the +/// command can reach. +pub(crate) struct Savepoint { + docs: Vec, + active: Option, + paint: PaintDefaults, + fill_active: bool, + draw_mode: DrawMode, + draw_inside: Option, + clipboard: Option, + untitled_counter: u32, + menu: cmd::menucmds::MenuState, + last_solid: crate::Color, + last_gradient: crate::GradientPaint, + recent_colors: Vec, + pending_paint: Option, + choices: Choices, + journal: usize, +} + +impl Savepoint { + pub(crate) fn take(s: &Session, id: &str) -> Self { + Self { + docs: s.docs.clone(), + active: s.active, + paint: s.paint.clone(), + fill_active: s.fill_active, + draw_mode: s.draw_mode, + draw_inside: s.draw_inside, + clipboard: CAN_FAIL_AFTER_COPYING.contains(&id).then(|| s.clipboard.clone()), + untitled_counter: s.untitled_counter, + menu: s.menu.clone(), + last_solid: s.last_solid, + last_gradient: s.last_gradient.clone(), + recent_colors: s.recent_colors.clone(), + pending_paint: s.pending_paint.clone(), + choices: Choices::of(s), + journal: s.journal.len(), + } + } + + pub(crate) fn restore(self, s: &mut Session) { + s.docs = self.docs; + s.active = self.active; + s.paint = self.paint; + s.fill_active = self.fill_active; + s.draw_mode = self.draw_mode; + s.draw_inside = self.draw_inside; + if let Some(clipboard) = self.clipboard { + s.clipboard = clipboard; + } + s.untitled_counter = self.untitled_counter; + s.menu = self.menu; + s.last_solid = self.last_solid; + s.last_gradient = self.last_gradient; + s.recent_colors = self.recent_colors; + s.pending_paint = self.pending_paint; + self.choices.put(s); + s.journal.truncate(self.journal); + } +} + +/// The counters and the session a script's host functions share. Between slices `session` holds a +/// spare session; during a slice it holds the real one, because the interpreter cannot be given a +/// borrow of it. +struct Host { + session: Rc>, + pause: Rc>, + /// The run is sliced by frames, so the person at the computer can be asked to continue. + framed: bool, + calls: Cell, + calls_this_slice: Cell, + objects: Cell, + bytes_kept: Cell, + bridge: Cell, + allowance_objects: Cell, + allowance_bytes: Cell, + continues: Cell, + /// Past the growth allowance, waiting for the person's answer. + waiting: Cell, + log: RefCell>, + log_bytes: Cell, + written: RefCell>, +} + +impl Host { + fn over(&self, limit: &str) -> HostError { + HostError::Abort(limit.to_string()) + } + + fn charge_call(&self, bytes: u64) -> std::result::Result<(), HostError> { + let calls = self.calls.get() + 1; + self.calls.set(calls); + if calls > MAX_HOST_CALLS { + return Err(self.over(OVER_CALLS)); + } + let bridge = self.bridge.get().saturating_add(bytes); + self.bridge.set(bridge); + if bridge > MAX_BRIDGE_BYTES { + return Err(self.over(OVER_BRIDGE)); + } + let n = self.calls_this_slice.get() + 1; + self.calls_this_slice.set(n); + if n >= SLICE_HOST_CALLS { + self.pause.set(true); + } + Ok(()) + } + + /// Counts what a command added to the documents, past which the person is asked or, without + /// anyone to ask, the run ends. A run that goes on past twice its allowance within one slice + /// ends too. + fn charge_growth(&self, objects: u64, bytes: u64) -> std::result::Result<(), HostError> { + self.objects.set(self.objects.get().saturating_add(objects)); + self.bytes_kept.set(self.bytes_kept.get().saturating_add(bytes)); + let (o, b) = (self.objects.get(), self.bytes_kept.get()); + let (limit_o, limit_b) = (self.allowance_objects.get(), self.allowance_bytes.get()); + if o <= limit_o && b <= limit_b { + return Ok(()); + } + let may_ask = self.framed && self.continues.get() < MAX_CONTINUES; + if !may_ask || o > limit_o.saturating_mul(2) || b > limit_b.saturating_mul(2) { + return Err(self.over(OVER_GROWTH)); + } + self.waiting.set(true); + self.pause.set(true); + Ok(()) + } +} + +/// A run in progress. +struct Job { + realm: Realm, + host: Rc, + /// Between slices the working copy; during the last slice's commit the state before the run. + world: World, + /// The documents open when the run began, by [`DocState::uid`]. + start_uids: Vec, + name: String, +} + +/// The `Data` for a JSON value, within `limits`. When it does not fit, the error is the place in +/// `v` where it stopped fitting, such as `.layers[2].name`, and empty for `v` itself. +pub(crate) fn to_data(v: &Value, limits: &DataLimits, depth: usize, used: &mut (usize, usize)) -> std::result::Result { + if depth > limits.depth { + return Err(String::new()); + } + used.0 = used.0.saturating_add(16); + used.1 = used.1.saturating_add(1); + if used.0 > limits.bytes || used.1 > limits.entries { + return Err(String::new()); + } + Ok(match v { + Value::Null => Data::Null, + Value::Bool(b) => Data::Bool(*b), + Value::Number(n) => Data::Number(n.as_f64().unwrap_or(f64::NAN)), + Value::String(s) => { + used.0 = used.0.saturating_add(s.len()); + if used.0 > limits.bytes { + return Err(String::new()); + } + Data::String(s.clone()) + } + Value::Array(a) => Data::Array( + a.iter() + .enumerate() + .map(|(i, x)| to_data(x, limits, depth + 1, used).map_err(|place| format!("[{i}]{place}"))) + .collect::>()?, + ), + Value::Object(o) => Data::Object( + o.iter() + .map(|(k, x)| { + used.0 = used.0.saturating_add(k.len()); + to_data(x, limits, depth + 1, used).map(|d| (k.clone(), d)).map_err(|place| format!(".{k}{place}")) + }) + .collect::>()?, + ), + }) +} + +/// The JSON for a value a script gave; a whole number within a double's exact range is an integer, +/// which is how commands read ids and counts. +fn to_json(d: &Data) -> Value { + match d { + Data::Undefined | Data::Null => Value::Null, + Data::Bool(b) => Value::Bool(*b), + Data::Number(n) => + { + #[allow(clippy::cast_possible_truncation)] + if n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_992.0 { + json!(*n as i64) + } else { + serde_json::Number::from_f64(*n).map_or(Value::Null, Value::Number) + } + } + Data::String(s) => Value::String(s.clone()), + Data::Array(a) => Value::Array(a.iter().map(to_json).collect()), + Data::Object(o) => Value::Object(o.iter().map(|(k, v)| (k.clone(), to_json(v))).collect()), + } +} + +/// Estimated bytes of `d`. +fn data_size(d: &Data) -> u64 { + match d { + Data::Undefined | Data::Null | Data::Bool(_) => 4, + Data::Number(_) => 8, + Data::String(s) => s.len() as u64 + 2, + Data::Array(a) => 8 + a.iter().map(data_size).sum::(), + Data::Object(o) => 8 + o.iter().map(|(k, v)| k.len() as u64 + 2 + data_size(v)).sum::(), + } +} + +/// Brings each open document's next id up to the highest one the session has given out for it. +fn raise_ids(s: &mut Session) { + let marks = std::mem::take(&mut s.script_high_water); + for st in &mut s.docs { + if let Some(&mark) = marks.get(&st.uid) + && mark > st.doc.peek_next_id() + { + st.change_unsaved(|d| Arc::make_mut(d).raise_next_id(mark)); + } + } + s.script_high_water = marks; +} + +fn note_ids(s: &mut Session) { + for st in &s.docs { + let mark = s.script_high_water.entry(st.uid).or_insert(0); + *mark = (*mark).max(st.doc.peek_next_id()); + } +} + +/// Runs command `id` inside a slice. A command that fails puts everything back it had done. +/// → its result, the objects it added, the bytes of document data it added and the state before +/// it, for a result that cannot cross to the script. +fn step(s: &mut Session, host: &Host, id: &str, params: &Value) -> Result<(Value, u64, u64, Option)> { + raise_ids(s); + let mutating = waits_for_tool_edit(id); + let save = mutating.then(|| Savepoint::take(s, id)); + let before: Vec<(u64, Arc, u64)> = + if mutating { s.docs.iter().map(|d| (d.uid, d.doc.clone(), d.doc.peek_next_id())).collect() } else { Vec::new() }; + crate::file_access::start_recording_writes(); + let r = s.execute(id, params); + // A file a command wrote before it failed is on disk all the same. + let mut written = host.written.borrow_mut(); + for path in crate::file_access::finish_recording_writes(r.is_ok()) { + if !written.contains(&path) { + written.push(path); + } + } + drop(written); + match r { + Ok(v) => { + note_ids(s); + let (mut objects, mut bytes) = (0, 0); + for st in &s.docs { + if let Some((_, d, ids)) = before.iter().find(|(uid, ..)| *uid == st.uid) + && !Arc::ptr_eq(d, &st.doc) + { + objects += st.doc.peek_next_id().saturating_sub(*ids); + bytes += d.retained_bytes_each(&st.doc).1 as u64; + } + } + Ok((v, objects, bytes, save)) + } + Err(e) => { + if let Some(save) = save { + save.restore(s); + } + Err(e) + } + } +} + +fn vc_run(host: &Rc, args: Vec) -> std::result::Result { + let Some(Data::String(id)) = args.first() else { return Err(HostError::Type(NO_COMMAND_ID.to_string())) }; + if args.len() > 2 { + return Err(HostError::Type(TOO_MANY_ARGUMENTS.to_string())); + } + let id = id.clone(); + let params = match args.get(1) { + None | Some(Data::Undefined) => json!({}), + Some(d @ Data::Object(_)) => to_json(d), + Some(_) => return Err(HostError::Type(PARAMS_NOT_OBJECT.to_string())), + }; + let bytes = args.iter().map(data_size).sum(); + host.charge_call(bytes)?; + let session = host.session.clone(); + let r = { + let mut s = session.borrow_mut(); + step(&mut s, host, &id, ¶ms) + }; + let (value, objects, added, save) = match r { + Ok(done) => done, + // A bug is not something a script can handle, and neither is a request to stop. + Err(e @ EngineError::Internal { .. }) => return Err(HostError::Abort(e.to_string())), + Err(EngineError::Cancelled) => return Err(HostError::Abort("canceled".into())), + Err(e) => return Err(HostError::Error(e.to_string())), + }; + let crossed = host.charge_growth(objects, added).and_then(|()| { + let data = to_data(&value, &result_limits(), 0, &mut (0, 0)) + .map_err(|place| HostError::Range(format!("vc.run: the result{place} is too large for a script")))?; + host.charge_call(data_size(&data))?; + Ok(data) + }); + // A command whose result does not reach the script is put back, like one that failed. + if crossed.is_err() + && let Some(save) = save + { + save.restore(&mut session.borrow_mut()); + } + crossed +} + +/// A bounded snapshot of the active document, or `null` with none open. A snapshot past the limits +/// of a crossing throws a `RangeError`. +fn vc_document(host: &Host) -> std::result::Result { + let s = host.session.borrow(); + let Some(st) = s.active() else { return Ok(Data::Null) }; + let ids: Vec = st.selection.objects.iter().take(1_000).map(|i| i.0).collect(); + let v = json!({ + "name": st.title(), + "path": st.path, + "modified": st.is_dirty(), + "artboards": st.doc.artboards.len(), + "layers": st.doc.layers.len(), + "selection": ids, + "selectionCount": st.selection.objects.len(), + }); + to_data(&v, &DataLimits::default(), 0, &mut (0, 0)).map_err(|place| HostError::Range(format!("vc.document{place} is too large for a script"))) +} + +/// Every command a script may run: its id, label and parameters. +fn vc_commands() -> Data { + Data::Array( + command_specs() + .iter() + .filter(|c| !refused_to_scripts(c.id)) + .map(|c| { + Data::Object(vec![ + ("id".into(), Data::String(c.id.into())), + ("label".into(), Data::String(c.label.into())), + ("params".into(), Data::String(c.params.into())), + ]) + }) + .collect(), + ) +} + +fn vc_log(host: &Host, args: Vec) -> std::result::Result { + let entry = args.first().unwrap_or(&Data::Undefined); + let bytes = data_size(entry) as usize; + if host.log.borrow().len() >= MAX_LOG_LINES || host.log_bytes.get().saturating_add(bytes) > MAX_LOG_BYTES { + return Err(HostError::Range(LOG_FULL.to_string())); + } + host.log_bytes.set(host.log_bytes.get() + bytes); + host.log.borrow_mut().push(to_json(entry)); + Ok(Data::Undefined) +} + +fn function(f: impl Fn(Vec) -> std::result::Result + 'static) -> HostFunction { + Rc::new(f) +} + +fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { + let run = { + let h = host.clone(); + function(move |a| vc_run(&h, a)) + }; + let document = { + let h = host.clone(); + function(move |_| { + h.charge_call(64)?; + vc_document(&h) + }) + }; + let commands = { + let h = host.clone(); + let list = Rc::new(vc_commands()); + let size = data_size(&list); + function(move |_| { + h.charge_call(size)?; + Ok((*list).clone()) + }) + }; + let log = { + let h = host.clone(); + function(move |a| { + h.charge_call(data_size(a.first().unwrap_or(&Data::Undefined)))?; + vc_log(&h, a) + }) + }; + let has = function(|a| Ok(Data::Bool(matches!(a.first(), Some(Data::String(f)) if API_FEATURES.contains(&f.as_str()))))); + vec![ + ("run".into(), HostItem::Function(run)), + ("document".into(), HostItem::Getter(document)), + ("commands".into(), HostItem::Getter(commands)), + ("log".into(), HostItem::Function(log)), + ("args".into(), HostItem::Value(args)), + ( + "api".into(), + HostItem::Object(vec![("version".into(), HostItem::Value(Data::String(API_VERSION.into()))), ("has".into(), HostItem::Function(has))]), + ), + ] +} + +/// A plain-words kind of a run's failure, as the report gives it. +fn kind_id(kind: &RunErrorKind) -> &'static str { + match kind { + RunErrorKind::Syntax => "syntax", + RunErrorKind::Thrown => "thrown", + RunErrorKind::Limit(LimitKind::Interrupted) => "stopped", + RunErrorKind::Limit(_) => "limit", + RunErrorKind::Internal => "internal", + } +} + +/// `message`, with what to do about a failure that comes from a script written for another program. +fn explained(kind: &RunErrorKind, message: &str, source: &str, line: u32) -> String { + const HOST_OBJECTS: &[&str] = + &["app", "$", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; + let names_host_object = + message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o))); + let directive = + *kind == RunErrorKind::Syntax && line > 0 && source.lines().nth(line as usize - 1).is_some_and(|l| l.trim_start().starts_with('#')); + if *kind == RunErrorKind::Thrown && names_host_object { + format!("{message} (scripts here use the `vc` object; objects other applications give their scripts are not available)") + } else if directive { + format!("{message} (preprocessor lines such as `#target` and `#include` are not read yet)") + } else { + message.to_string() + } +} + +impl Session { + /// Is a script running? While one is, the commands that change anything are refused. + pub fn script_running(&self) -> bool { + self.script_key.is_some() + } + + /// The flag that stops the script running in this session from any thread, at its next + /// instruction. + pub fn script_interrupt(&self) -> Interrupt { + self.script_stop.clone() + } + + /// The report of the run that ended last (`script.run`'s result, or its failure). + pub fn script_report(&self) -> Option<&Value> { + self.script_report.as_ref() + } + + /// The error text of the last run when it failed: its name, place, message and kind, and the + /// files it left written. + pub fn script_failure_text(&self) -> Option { + self.script_report.as_ref().filter(|r| r["ok"] == false).map(failure_text) + } + + /// Has the running script used up its growth allowance, so a person must answer? A run asked + /// to stop no longer waits: the next [`Session::script_pump`] ends it. + pub fn script_waiting(&self) -> bool { + !self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false) + } + + /// The name of the running script. + pub fn script_name(&self) -> Option { + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone())) + } + + /// Lets the run go past its growth allowance by one more allowance. + pub fn script_continue(&mut self) { + if let Some(k) = &self.script_key { + with_job(k, |j| { + let h = &j.host; + if h.waiting.replace(false) { + h.continues.set(h.continues.get() + 1); + h.allowance_objects.set(h.allowance_objects.get().saturating_add(growth_objects())); + h.allowance_bytes.set(h.allowance_bytes.get().saturating_add(GROWTH_BYTES)); + } + }); + } + } + + /// Asks the running script to stop; it is rolled back at the next slice. False with none running. + pub fn script_request_stop(&mut self) -> bool { + if self.script_key.is_none() { + return false; + } + self.script_stop.set(); + true + } + + /// Stops the running script at once and puts everything back, with the report of a stopped run + /// ([`Self::script_report`]). False with none running. + pub fn script_abandon(&mut self) -> bool { + let Some(key) = self.script_key.take() else { return false }; + let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { + drop(key); + raise_ids(self); + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + return true; + }; + drop(key); + let kind = LimitKind::Interrupted; + let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; + let _ = self.script_failed(job, &e); + true + } + + /// A script can't start while a document holds a drag in progress or an undo group, which its + /// one undo step could not take in. + fn settle_for_script(&mut self) -> Result<()> { + self.finish_tool_edit_later()?; + if self.active().is_some() { + cmd::settle_interaction(self)?; + } + if self.explicit_undo_group.is_some() || self.docs.iter().any(|d| d.undo_group.is_some() || d.interaction.is_some()) { + return Err(EngineError::Other(OPEN_EDIT.to_string())); + } + Ok(()) + } + + /// Begins a run of `request`. + pub(crate) fn script_start(&mut self, request: Request) -> Result<()> { + self.settle_for_script()?; + // In a build where a fault ends the program, every unsaved document has a copy first. + if self.script_copy_first && self.docs.iter().any(DocState::is_dirty) && cmd::recovery::save_copies(self).is_err() { + return Err(EngineError::Other(NO_RECOVERY_COPY.to_string())); + } + self.reset_tool(); + let open: Vec = self.docs.iter().map(|d| d.uid).collect(); + self.script_high_water.retain(|uid, _| open.contains(uid)); + self.script_stop.clear(); + self.script_report = None; + let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() }; + let options = Options { max_source_units: MAX_SOURCE_BYTES.saturating_mul(2), ..Options::default() }; + let mut realm = Realm::with_options(options, limits); + realm.share_interrupt(&self.script_stop); + realm.set_data_limits(DataLimits::default()); + let host = Rc::new(Host { + session: Rc::new(RefCell::new(Session::new())), + pause: realm.pause_flag(), + framed: self.script_frames, + calls: Cell::new(0), + calls_this_slice: Cell::new(0), + objects: Cell::new(0), + bytes_kept: Cell::new(0), + bridge: Cell::new(0), + allowance_objects: Cell::new(growth_objects()), + allowance_bytes: Cell::new(GROWTH_BYTES), + continues: Cell::new(0), + waiting: Cell::new(false), + log: RefCell::new(Vec::new()), + log_bytes: Cell::new(0), + written: RefCell::new(Vec::new()), + }); + let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) + .map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?; + let started = realm.define_host_object("vc", vc_object(&host, args)).and_then(|()| realm.start(&request.source)); + if let Err(e) = started { + let report = failure_report(&request.name, &e, &request.source, &host, 0); + let message = failure_text(&report); + self.script_report = Some(report); + return Err(EngineError::Other(message)); + } + let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name }; + let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); + self.script_key = Some(JobKey(id)); + Ok(()) + } + + /// Advances the running script by one slice. `None` while it is still running; otherwise the + /// run has ended: its report, or the error that ended it, with the documents put back. + pub fn script_pump(&mut self) -> Option> { + let key = self.script_key.take()?; + let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { + return Some(Err(EngineError::Other(JOB_GONE.to_string()))); + }; + if job.host.waiting.get() && !self.script_stop.is_set() { + JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + self.script_key = Some(key); + return None; + } + if vectorcraft_doc::cancel::check().is_err() { + self.script_stop.set(); + } + job.host.calls_this_slice.set(0); + let outcome = run_slice(self, &mut job); + match outcome { + Err(panic) => { + let e = RunError { kind: RunErrorKind::Internal, message: panic, line: 0, column: 0 }; + Some(self.script_failed(job, &e)) + } + Ok(Err(e)) => Some(self.script_failed(job, &e)), + Ok(Ok(Progress::Paused)) => { + JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + self.script_key = Some(key); + None + } + Ok(Ok(Progress::Done(_))) => Some(self.script_commit(job)), + } + } + + fn script_failed(&mut self, job: Job, e: &RunError) -> Result { + let report = failure_report(&job.name, e, "", &job.host, job.realm.fuel_left()); + drop(job); + raise_ids(self); + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + let message = failure_text(&report); + self.script_report = Some(report); + Err(EngineError::Other(message)) + } + + /// Makes the working copy the session's state: one undo step for each document whose art + /// changed, nothing for the others. + fn script_commit(&mut self, mut job: Job) -> Result { + // The working copy goes into the session; `job.world` holds the state before the run. + job.world.swap(self); + self.prefs = merged_prefs(&job.world.start_prefs, &self.prefs, &job.world.choices.prefs); + note_ids(self); + raise_ids(self); + let start = std::mem::take(&mut job.world.docs); + for st in &self.docs { + let changed = start.iter().find(|b| b.uid == st.uid).is_none_or(|b| !st.same_content(&b.doc)); + if changed && (!crate::sanity::doc_sane(&st.doc, &st.selection) || st.doc.too_deep(None)) { + job.world.docs = start; + job.world.swap(self); + let e = RunError { kind: RunErrorKind::Internal, message: INVALID_RESULT.to_string(), line: 0, column: 0 }; + return self.script_failed(job, &e); + } + } + let mut changed_names = Vec::new(); + for st in &mut self.docs { + let Some(b) = start.iter().find(|b| b.uid == st.uid) else { + // A document the script opened or made starts with no undo steps. + st.history = crate::History { limit: st.history.limit, ..crate::History::default() }; + st.revision += 1; + changed_names.push(st.title()); + continue; + }; + let changed = !st.same_content(&b.doc); + st.history = b.history.clone(); + st.recovery.clone_from(&b.recovery); + if changed { + st.push_undo(STEP_LABEL.to_string(), b.doc.clone(), Arc::new(b.selection.clone())); + changed_names.push(st.title()); + } + if changed || st.revision != b.revision { + st.revision = st.revision.max(b.revision) + 1; + } + st.sync_new_art(); + } + // A document a frontend opened while the run went on stays open. + for st in start.iter().filter(|b| !job.start_uids.contains(&b.uid)) { + self.docs.push(st.clone()); + } + // A document the script closed is gone now, with its recovery copy. + for b in start.iter().filter(|b| job.start_uids.contains(&b.uid) && !self.docs.iter().any(|d| d.uid == b.uid)) { + cmd::recovery::forget_closed(self, b); + } + // A document the script saved has nothing left to recover. + cmd::recovery::forget_clean(self); + let entries: Vec<_> = self.journal.iter().cloned().collect(); + self.journal = std::mem::take(&mut job.world.journal); + for (id, p) in entries { + self.journal.push((id, p)); + } + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + let host = &job.host; + let report = json!({ + "ok": true, + "name": job.name, + "changed": changed_names, + "written": *host.written.borrow(), + "log": *host.log.borrow(), + "fuelUsed": job.realm.limits().fuel - job.realm.fuel_left(), + "hostCalls": host.calls.get(), + }); + self.script_report = Some(report.clone()); + Ok(report) + } + + /// Runs the running script until it ends. + pub(crate) fn script_run_to_end(&mut self) -> Result { + loop { + if let Some(result) = self.script_pump() { + return result; + } + if self.script_waiting() { + self.script_stop.set(); + } + } + } +} + +const OPEN_EDIT: &str = "a drag or an undo group is open; finish it before running a script"; +const INVALID_RESULT: &str = "the script left a document that cannot be kept"; +const JOB_GONE: &str = "the script's run is no longer available"; +// Text a script reads from the errors it catches, or that ends its run with its name and place. +const OVER_CALLS: &str = "the script made more host calls than one run may make"; +const OVER_BRIDGE: &str = "the script passed more data to and from commands than one run may pass"; +const OVER_GROWTH: &str = "the script added more objects and data to the documents than one run may add"; +const NO_COMMAND_ID: &str = "vc.run: give a command id, as a string"; +const TOO_MANY_ARGUMENTS: &str = "vc.run: takes a command id and an object of parameters"; +const PARAMS_NOT_OBJECT: &str = "vc.run: the parameters must be an object"; +const LOG_FULL: &str = "vc.log: the log of this run is full"; +const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the script was not run"; + +/// The report of a run that failed. +fn failure_report(name: &str, e: &RunError, source: &str, host: &Host, fuel_left: u64) -> Value { + json!({ + "ok": false, + "kind": kind_id(&e.kind), + "message": explained(&e.kind, &e.message, source, e.line), + "name": name, + "line": e.line, + "column": e.column, + "written": *host.written.borrow(), + "log": *host.log.borrow(), + "fuelLeft": fuel_left, + }) +} + +/// The text of a failed run's error. +fn failure_text(report: &Value) -> String { + let s = |k: &str| report[k].as_str().unwrap_or_default(); + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let at = if line > 0 { format!(" line {line}, column {column}") } else { String::new() }; + let written = report["written"].as_array().map_or(0, Vec::len); + let kept = if written > 0 { + let paths: Vec<&str> = report["written"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + format!(" (these files stay written: {})", paths.join(", ")) + } else { + String::new() + }; + format!("{}:{at}: {} ({}){kept}", s("name"), s("message"), s("kind")) +} + +/// A run's slice: the working copy in, the real session lent to the host functions, one measured +/// slice run, and everything back. A panic in the interpreter is the outer `Err`. +fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result, String> { + let depth = std::mem::replace(&mut s.depth, 0); + job.world.swap(s); + s.script_in_run = true; + let cell = job.host.session.clone(); + std::mem::swap(s, &mut *cell.borrow_mut()); + let realm = &mut job.realm; + let r = guard::catch_panic(|| { + #[cfg(test)] + if PANIC_IN_SLICE.with(Cell::get) { + panic!("a slice failed"); + } + realm.advance(SLICE_FUEL) + }); + std::mem::swap(s, &mut *cell.borrow_mut()); + s.script_in_run = false; + job.world.swap(s); + s.depth = depth; + r +} + +/// The objects a run may add before it is asked to continue or stop. +fn growth_objects() -> u64 { + #[cfg(test)] + if let Some(n) = TEST_GROWTH.with(Cell::get) { + return n; + } + GROWTH_OBJECTS +} + +/// The limits of a command's result on its way to the script. +fn result_limits() -> DataLimits { + #[cfg(test)] + if let Some(bytes) = TEST_RESULT_BYTES.with(Cell::get) { + return DataLimits { bytes, ..DataLimits::default() }; + } + DataLimits::default() +} + +#[cfg(test)] +thread_local! { + /// Tests: the bytes a command's result may hold on its way to the script. + pub(crate) static TEST_RESULT_BYTES: Cell> = const { Cell::new(None) }; + /// Tests: makes the next slice panic. + pub(crate) static PANIC_IN_SLICE: Cell = const { Cell::new(false) }; + /// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`]. + pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 208088a4c..8a1ef8f69 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -129,6 +129,13 @@ impl Session { link_scan: None, global_edit_options: Default::default(), keyboard_text: false, + script_key: None, + script_stop: vectorcraft_js::Interrupt::new(), + script_report: None, + script_frames: false, + script_high_water: Default::default(), + script_in_run: false, + script_copy_first: cfg!(target_arch = "wasm32"), } } diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 7c72da22d..1020e11a5 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -7,6 +7,9 @@ impl Session { /// Run `f` on a mutable copy of the active document. Outside an interaction this records one /// undo step labelled `label`; inside one it just mutates (the interaction commits later). pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result { + if self.script_running() { + return Err(EngineError::Busy); + } // An edit made while typing (the Transform panel) is a step of its own: inside the typing // session, the next keystroke's preview would start again from before it (#1881). if !self.previewing && cmd::typing_in_progress(self) { @@ -225,6 +228,9 @@ impl Session { } pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + if self.script_running() { + return Err(EngineError::Busy); + } // Typing in progress is kept first: a drag or a dialog's preview would otherwise start // from the snapshot taken before the typing, and drop it (#1881). if label != "Typing" && cmd::typing_in_progress(self) { @@ -262,6 +268,9 @@ impl Session { /// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview). pub fn preview(&mut self, cmd: &str, params: &Value) -> Result { + if self.script_running() { + return Err(EngineError::Busy); + } let (paint, restore_paint) = { let menu = self.menu.clone(); let st = self.doc_mut()?; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 1a60c2101..c540ae043 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -39,6 +39,14 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + // A clipboard import is a query that replaces the clipboard, which the end of a run sets too. + if self.depth == 0 && self.script_key.is_some() && (waits_for_tool_edit(id) || id.starts_with("clipboard.import")) { + return Err(EngineError::Busy); + } + // A step of a batch a script runs is held to the same rule as the script's own commands. + if self.script_in_run && crate::script::refused_to_scripts(id) { + return Err(EngineError::Other(crate::script::refused_text(id))); + } // An edit the active tool is computing on another thread (a Shape Builder merge) is // applied before a command that may edit, select, save or give a document a new revision, // as the click came first. Queries that only read do not wait for it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 000741d5d..9dca9b363 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -149,6 +149,7 @@ mod repeat_edit; mod save; mod saveoptions; mod scalestrokes; +mod script_run; mod scripts; mod select_ids_first; mod shaper; diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index 37aac5df5..7fc8f0a4b 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -147,6 +147,26 @@ fn a_look_that_stores_its_stamps_between_the_polls_reads_keeps_them() { assert!(s.link_scan.is_none()); } +#[test] +fn a_change_during_a_script_waits_for_the_script_to_end() { + let dir = Folder::new("watch-script"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + s.prefs.update_links = "automatically".into(); + let id = place(&mut s, &path); + look(&mut s); + save(&path, &png(40, 40, BLUE)); + s.start_link_scan(); + s.wait_link_scan(); + s.script_frames = true; + s.execute("script.run", &json!({"source": "while (true) {}"})).unwrap(); + assert!(s.poll_link_scan().is_none(), "the finished look waits"); + assert!(s.script_abandon()); + assert_eq!(s.poll_link_scan().expect("the stamps are applied").unwrap()["updated"], json!([id.0])); + assert_eq!(colour(&s), BLUE); +} + #[test] fn a_change_during_a_drag_waits_for_the_drag_to_end() { let dir = Folder::new("watch-drag"); diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs new file mode 100644 index 000000000..7912c7b1d --- /dev/null +++ b/crates/engine/src/tests/script_run.rs @@ -0,0 +1,728 @@ +//! `script.run` and `script.stop`: one transaction per run, the rollback after a throw, a limit, a +//! stop or a panic, the mutation lease between slices, ids, growth and what a run reports. + +use std::cell::Cell; + +use serde_json::{Value, json}; + +use crate::script::{PANIC_IN_SLICE, TEST_GROWTH, TEST_RESULT_BYTES}; +use crate::*; + +fn rect(x: i64) -> Value { + json!({"x": x, "y": 0, "width": 10, "height": 10}) +} + +/// Two open documents, each with one rectangle, the second one active. +fn two_docs() -> Session { + let mut s = Session::new(); + for x in [0, 50] { + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &rect(x)).unwrap(); + } + s +} + +/// What a rollback has to put back. +fn state(s: &Session) -> Value { + let docs: Vec = s + .documents() + .iter() + .map(|d| { + json!({ + "uid": d.uid, + "art": format!("{:?}", d.doc.layers), + "selection": d.selection.objects.iter().map(|i| i.0).collect::>(), + "undo": d.history.undo.iter().map(|e| e.label.clone()).collect::>(), + "redo": d.history.redo.len(), + "path": d.path, + "dirty": d.is_dirty(), + }) + }) + .collect(); + json!({"docs": docs, "active": s.active().map(|d| d.uid), "journal": s.journal.len(), "paint": format!("{:?}", s.paint)}) +} + +fn run(s: &mut Session, source: &str) -> Result { + s.execute("script.run", &json!({"source": source, "name": "t.js"})) +} + +fn objects(s: &Session, doc: usize) -> usize { + s.documents()[doc].doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +/// A framed run of `source`, one slice in. +fn paused(s: &mut Session, source: &str) { + s.script_frames = true; + assert_eq!(run(s, source).unwrap()["running"], true); + assert!(s.script_pump().is_none()); + assert!(s.script_running()); +} + +fn two_rect_script() -> String { + "vc.run('shape.rectangle', {x:100,y:0,width:5,height:5});\ + vc.run('shape.rectangle', {x:110,y:0,width:5,height:5});\ + vc.run('document.activate', {index:0});\ + vc.run('shape.rectangle', {x:120,y:0,width:5,height:5});\ + vc.run('document.activate', {index:1});" + .to_string() +} + +#[test] +fn a_run_is_one_undo_step_in_each_document_it_changed() { + let mut s = two_docs(); + let before = state(&s); + let r = run(&mut s, &two_rect_script()).unwrap(); + assert_eq!(r["ok"], true); + assert_eq!((objects(&s, 0), objects(&s, 1)), (2, 3)); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script"); + assert_eq!(s.documents()[1].history.undo.last().unwrap().label, "Run Script"); + assert_eq!(s.documents()[1].history.undo.len(), before["docs"][1]["undo"].as_array().unwrap().len() + 1); + let after = format!("{:?}", s.documents()[1].doc.layers); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s, 1), 1); + assert_eq!(format!("{:?}", s.documents()[1].doc.layers), before["docs"][1]["art"].as_str().unwrap()); + assert_eq!(s.documents()[1].selection.objects.len(), before["docs"][1]["selection"].as_array().unwrap().len()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(format!("{:?}", s.documents()[1].doc.layers), after); +} + +#[test] +fn a_run_that_changes_nothing_leaves_no_step_no_mark_and_no_journal_entry() { + for source in ["", "var a = 1 + 2;", "vc.log(vc.document); vc.commands.length;"] { + let mut s = two_docs(); + s.execute("document.save", &json!({"path": std::env::temp_dir().join(format!("vc-noop-{}.svg", std::process::id())).to_string_lossy()})) + .unwrap(); + let before = state(&s); + let revisions = |s: &Session| s.documents().iter().map(|d| d.revision).collect::>(); + let revisions_before = revisions(&s); + let r = run(&mut s, source).unwrap(); + assert_eq!(r["changed"], json!([]), "{source}"); + assert_eq!(state(&s), before, "{source}"); + assert_eq!(revisions(&s), revisions_before, "{source}"); + } +} + +#[test] +fn a_caught_command_failure_leaves_nothing_behind_and_the_script_goes_on() { + let mut s = two_docs(); + let source = "var m = '';\ + try { vc.run('command.batch', {commands: [\ + {command:'shape.rectangle', params:{x:1,y:1,width:1,height:1}},\ + {command:'no.such', params:{}}]}); } catch (e) { m = e.message; }\ + try { vc.run('shape.rectangle', {x:'bad'}); } catch (e) { m += '|' + e.name; }\ + vc.log(m); vc.run('shape.rectangle', {x:200,y:0,width:5,height:5});"; + let r = run(&mut s, source).unwrap(); + assert_eq!(objects(&s, 1), 2, "{r}"); + assert!(r["log"][0].as_str().unwrap().contains("|Error"), "{r}"); +} + +#[test] +fn a_failure_puts_back_every_document_and_the_session() { + enum How { + Throw, + Fuel, + Stop, + Panic, + } + for how in [How::Throw, How::Fuel, How::Stop, How::Panic] { + let mut s = two_docs(); + let before = state(&s); + let edits = two_rect_script(); + let r = match how { + How::Throw => run(&mut s, &format!("{edits} throw new Error('boom');")), + How::Fuel => s.execute("script.run", &json!({"source": format!("{edits} while (true) {{}}"), "maxFuel": 2_000_000})), + How::Stop => { + paused(&mut s, &format!("{edits} while (true) {{}}")); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + s.script_pump().unwrap() + } + How::Panic => { + paused(&mut s, &format!("{edits} while (true) {{}}")); + PANIC_IN_SLICE.with(|p| p.set(true)); + let r = s.script_pump().unwrap(); + PANIC_IN_SLICE.with(|p| p.set(false)); + r + } + }; + assert!(r.is_err()); + assert_eq!(state(&s), before); + assert!(!s.script_running()); + s.script_frames = false; + assert_eq!(run(&mut s, "1").unwrap()["ok"], true, "a later run starts"); + } +} + +#[test] +fn a_stop_with_no_run_reports_it() { + assert_eq!(Session::new().execute("script.stop", &json!({})).unwrap()["stopped"], false); +} + +#[test] +fn an_error_names_the_script_its_place_and_kind() { + let mut s = two_docs(); + let e = run(&mut s, "var a;\n throw new TypeError('no');").unwrap_err().to_string(); + assert_eq!(e, "t.js: line 2, column 3: TypeError: no (thrown)"); + let e = run(&mut s, "var = 1;").unwrap_err().to_string(); + assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}"); + assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`")); + assert!(run(&mut s, "#target x\n1").unwrap_err().to_string().contains("#target")); + assert_eq!(s.script_report().unwrap()["line"], 1); +} + +#[test] +fn bad_parameters_are_refused_before_anything_runs() { + let mut s = two_docs(); + let before = state(&s); + let mut deep = json!(1); + for _ in 0..100 { + deep = json!([deep]); + } + for p in [ + json!({}), + json!({"source": "1", "args": {"a": deep}}), + json!({"path": "a", "source": "b"}), + json!({"path": ""}), + json!({"path": 1}), + json!({"source": "1", "nope": 1}), + json!({"source": "1", "args": []}), + json!({"source": "1", "args": {"a": "x".repeat(1 << 20)}}), + json!({"source": "1", "answers": {"a": 1}}), + json!({"source": "1", "answers": 1}), + json!({"source": "1", "maxFuel": 0}), + json!({"source": "1", "maxFuel": -1}), + json!({"source": "1", "maxFuel": 1.5}), + json!({"source": "1", "maxFuel": "9"}), + json!({"source": "1", "maxFuel": 100_000_000_001u64}), + ] { + assert!(matches!(s.execute("script.run", &p), Err(EngineError::BadParams { .. })), "{p}"); + } + assert_eq!(state(&s), before); +} + +#[test] +fn files_are_checked_before_they_are_read() { + let dir = std::env::temp_dir().join(format!("vc-script-run-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let write = |name: &str, bytes: &[u8]| { + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().into_owned() + }; + let mut s = two_docs(); + let path = |p: &str| json!({"path": p}); + let ok = write("Bézier (1) & co.js", b"\xef\xbb\xbfvc.log(1);\r\nvc.log(2);\r\n"); + let r = s.execute("script.run", &path(&ok)).unwrap(); + assert_eq!((r["log"].clone(), r["name"].as_str()), (json!([1, 2]), Some("Bézier (1) & co.js"))); + let invalid = write("a.js", b"var a = '\xff';"); + assert!(s.execute("script.run", &path(&invalid)).unwrap_err().to_string().contains("UTF-8")); + let huge = write("huge.js", &vec![b' '; script::MAX_SOURCE_BYTES + 1]); + assert!(s.execute("script.run", &path(&huge)).unwrap_err().to_string().contains("too large")); + let compiled = write("b.jsxbin", b"@JSXBIN@ES@2.0@"); + assert!(s.execute("script.run", &path(&compiled)).unwrap_err().to_string().contains("compiled")); + let renamed = write("c.js", b"@JSXBIN@ES@2.0@"); + assert!(s.execute("script.run", &path(&renamed)).unwrap_err().to_string().contains("compiled")); + assert!(s.execute("script.run", &path(&dir.to_string_lossy())).is_err()); + let huge_source = " ".repeat(script::MAX_SOURCE_BYTES + 1); + assert!(run(&mut s, &huge_source).unwrap_err().to_string().contains("too large")); + let other = write("notes.txt", b"vc.log(1);"); + assert!(matches!(s.execute("script.run", &path(&other)), Err(EngineError::BadParams { .. })), "a text file that is not a script"); + let upper = write("UP.JSX", b"vc.log(3);"); + assert_eq!(s.execute("script.run", &path(&upper)).unwrap()["log"], json!([3])); + std::fs::create_dir_all(dir.join("folder.js")).unwrap(); + assert!(s.execute("script.run", &path(&dir.join("folder.js").to_string_lossy())).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_failed_batch_puts_back_the_session_and_names_a_file_it_wrote() { + let path = std::env::temp_dir().join(format!("vc-script-batch-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + let source = format!( + "try {{ vc.run('command.batch', {{commands: [\ + {{command: 'view.guides.lock', params: {{locked: true}}}},\ + {{command: 'document.export', params: {{path: {p:?}, format: 'svg'}}}},\ + {{command: 'no.such', params: {{}}}}]}}); }} catch (e) {{ vc.log(e.name); }}" + ); + let r = run(&mut s, &source).unwrap(); + assert_eq!(r["log"], json!(["Error"]), "{r}"); + assert!(!s.guides_locked(), "the failed batch's Lock Guides does not stay"); + assert!(path.exists()); + assert_eq!(r["written"], json!([p]), "{r}"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn an_export_that_fails_names_no_file() { + let path = std::env::temp_dir().join(format!("vc-script-no-folder-{}", std::process::id())).join("out.svg"); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + let source = format!("try {{ vc.run('document.export', {{path: {p:?}, format: 'svg'}}); }} catch (e) {{ vc.log(e.name); }}"); + let r = run(&mut s, &source).unwrap(); + assert_eq!(r["log"], json!(["Error"]), "{r}"); + assert!(!path.exists()); + assert_eq!(r["written"], json!([]), "{r}"); +} + +#[test] +fn a_failed_run_puts_back_the_choices_its_commands_made() { + let mut s = two_docs(); + let before = s.global_edit_options.clone(); + let source = "vc.run('select.globalEditOptions', {appearance: !vc.run('select.globalEditOptions', {}).appearance}); throw new Error('x');"; + assert!(run(&mut s, source).is_err()); + assert_eq!(s.global_edit_options, before); + let source = "try { vc.run('command.batch', {commands: [\ + {command: 'select.globalEditOptions', params: {size: true}},\ + {command: 'no.such', params: {}}]}); } catch (e) {}"; + run(&mut s, source).unwrap(); + assert_eq!(s.global_edit_options, before, "the failed command's choice went back"); +} + +#[test] +fn a_failed_run_puts_back_the_preferences_its_commands_changed() { + let mut s = two_docs(); + let basic = s.prefs.new_art_basic; + assert!(run(&mut s, &format!("vc.run('appearance.setNewArtBasic', {{on: {}}}); throw new Error('x');", !basic)).is_err()); + assert_eq!(s.prefs.new_art_basic, basic); +} + +#[test] +fn a_finished_run_keeps_its_preferences_and_the_ones_the_app_changed_meanwhile() { + let mut s = two_docs(); + let (basic, increment) = (s.prefs.new_art_basic, s.prefs.keyboard_increment); + paused(&mut s, &format!("vc.run('appearance.setNewArtBasic', {{on: {}}}); for (var i = 0; i < 2000000; i++) {{}}", !basic)); + assert_eq!(s.prefs.new_art_basic, basic, "between slices the app sees the preferences before the run"); + s.prefs.keyboard_increment = increment + 1.0; + while s.script_pump().is_none() {} + assert_eq!((s.prefs.new_art_basic, s.prefs.keyboard_increment), (!basic, increment + 1.0)); +} + +#[test] +fn a_script_cannot_load_the_place_cursor() { + let mut s = two_docs(); + let e = run(&mut s, "vc.run('file.place.queue', {paths: []});").unwrap_err().to_string(); + assert!(e.contains("a script cannot run `file.place.queue`"), "{e}"); + let listed = run(&mut s, "vc.log(vc.commands.some(function (c) { return c.id === 'file.place.queue'; }));").unwrap(); + assert_eq!(listed["log"], json!([false]), "{listed}"); +} + +#[test] +fn a_command_whose_result_cannot_cross_is_put_back() { + TEST_RESULT_BYTES.with(|b| b.set(Some(1))); + let mut s = two_docs(); + let r = run(&mut s, "try { vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); } catch (e) { vc.log(e.name); }"); + TEST_RESULT_BYTES.with(|b| b.set(None)); + let r = r.unwrap(); + assert_eq!(r["log"], json!(["RangeError"]), "{r}"); + assert_eq!(r["changed"], json!([]), "{r}"); + assert_eq!((objects(&s, 0), objects(&s, 1)), (1, 1)); +} + +#[test] +fn an_abandoned_run_reports_the_files_it_wrote() { + let path = std::env::temp_dir().join(format!("vc-script-abandon-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + paused(&mut s, &format!("vc.run('document.export', {{path: {p:?}, format: 'svg'}}); while (true) {{}}")); + assert!(path.exists(), "the first slice exported"); + assert!(s.script_abandon()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), report["written"].clone()), (Some("stopped"), json!([p])), "{report}"); + assert!(s.script_failure_text().unwrap().contains(&p)); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_run_that_only_closes_a_document_keeps_its_journal_entry() { + let mut s = two_docs(); + let journal = s.journal.len(); + let r = run(&mut s, "vc.run('file.close', {index: 1});").unwrap(); + assert_eq!(r["changed"], json!([]), "{r}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.journal.len(), journal + 1); + assert_eq!(s.journal.iter().last().unwrap().0, "file.close"); +} + +#[test] +fn a_command_that_can_fail_after_copying_has_the_clipboard_put_back() { + let mut s = two_docs(); + let keep_clipboard = |s: &mut Session, id: &str| { + let save = script::Savepoint::take(s, id); + s.clipboard.source_artboard = Some(Default::default()); + save.restore(s); + }; + keep_clipboard(&mut s, "edit.cut"); + assert_eq!(s.clipboard.source_artboard, None, "put back"); + s.clipboard.source_artboard = Some(Default::default()); + keep_clipboard(&mut s, "shape.rectangle"); + assert!(s.clipboard.source_artboard.is_some(), "only those commands pay for a copy of the clipboard"); +} + +#[test] +fn a_snapshot_too_large_to_cross_throws_instead_of_reading_as_no_document() { + let mut s = two_docs(); + s.docs[1].path = Some("x".repeat(17 << 20)); + let r = run(&mut s, "var m = ''; try { vc.document; m = 'read'; } catch (e) { m = e.name + ': ' + e.message; } vc.log(m);").unwrap(); + let m = r["log"][0].as_str().unwrap(); + assert!(m.starts_with("RangeError: vc.document.") && m.ends_with("is too large for a script"), "{m}"); +} + +#[test] +fn recovery_copies_made_between_slices_hold_the_art_before_the_run() { + let store = std::sync::Arc::new(cmd::recovery::MemoryStore::default()); + let mut s = two_docs(); + s.recovery.set_store(store.clone()); + paused(&mut s, &format!("{} while (true) {{}}", two_rect_script())); + cmd::recovery::save_copies(&mut s).unwrap(); + s.script_abandon(); + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store); + let r = s.execute("file.recovery.restore", &json!({})).unwrap(); + assert_eq!(r["restored"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!((objects(&s, 0), objects(&s, 1)), (1, 1), "the copies hold the art from before the run"); +} + +#[test] +fn values_that_cannot_cross_are_named_and_shared_ones_can() { + let mut s = two_docs(); + let cases = [ + ("var a = {}; a.a = a; vc.run('shape.rectangle', a);", "RangeError|TypeError"), + ("var a = {}, c = a; for (var i = 0; i < 100; i++) { c.n = {}; c = c.n; } vc.run('shape.rectangle', a);", "RangeError|TypeError"), + ("vc.run('shape.rectangle', {x: 1/0});", "RangeError|TypeError"), + ("vc.run('shape.rectangle', {x: function () {}});", "RangeError|TypeError"), + ("vc.run('shape.rectangle', 7);", "TypeError"), + ("vc.run(7);", "TypeError"), + ]; + for (source, kinds) in cases { + let e = run(&mut s, source).unwrap_err().to_string(); + assert!(kinds.split('|').any(|k| e.contains(k)), "{source}: {e}"); + } + let r = run(&mut s, "var p = {w: 5}; vc.log({a: p, b: p}); var l = ''; for (var i = 0; i < 20; i++) l += 'xxxxxxxxxx'; vc.log(l);").unwrap(); + assert_eq!(r["log"][0], json!({"a": {"w": 5}, "b": {"w": 5}})); + assert_eq!(r["log"][1].as_str().unwrap().len(), 200); +} + +#[test] +fn commands_outside_the_documents_are_refused_in_a_batch_too() { + let mut s = two_docs(); + for source in [ + "vc.run('prefs.set', {key: 'a', value: 1});", + "vc.run('script.run', {source: '1'});", + "vc.run('script.stop');", + "vc.run('edit.undoGroup.begin', {});", + "vc.run('view.proofColors', {on: true});", + "vc.run('view.proofSetup', {});", + "vc.run('view.overprintPreview', {on: true});", + "vc.run('view.separationsPreview', {});", + "vc.run('command.batch', {commands: [{command: 'prefs.reset', params: {}}]});", + ] { + let e = run(&mut s, source).unwrap_err().to_string(); + assert!(e.contains("cannot run"), "{source}: {e}"); + } + let r = run(&mut s, "var c = vc.commands; var n = 0; for (var i = 0; i < c.length; i++) if (c[i].id.indexOf('prefs.') === 0) n++; vc.log(n); vc.log(c.length > 100);").unwrap(); + assert_eq!(r["log"], json!([0, true])); +} + +#[test] +fn the_vc_object_reads_the_active_document_and_cannot_be_changed() { + let mut s = two_docs(); + let r = s + .execute( + "script.run", + &json!({"name": "x", "args": {"n": 2}, "source": "vc.args.n = 9; vc.api.version = 'x'; vc.log(vc.args.n); vc.log(vc.api.version); vc.log(vc.api.has('run')); vc.log(vc.api.has('nope')); vc.log(vc.document.artboards); vc.log(typeof vc.document.path);"}), + ) + .unwrap(); + assert_eq!(r["log"], json!([2, "1.0", true, false, 1, "object"])); + let mut empty = Session::new(); + assert_eq!(run(&mut empty, "vc.log(vc.document === null);").unwrap()["log"], json!([true])); + assert!(run(&mut empty, "vc.run('shape.rectangle', {x:0,y:0,width:1,height:1});").unwrap_err().to_string().contains("Error")); +} + +#[test] +fn the_log_is_bounded_and_a_script_can_catch_that() { + let mut s = two_docs(); + let r = run( + &mut s, + "var n = 0; try { while (true) { vc.log(n); n++; } } catch (e) { vc.run('document.inspect', {}); throw new Error(e.name + ' at ' + n); }", + ); + let e = r.unwrap_err().to_string(); + assert!(e.contains(&format!("RangeError at {}", script::MAX_LOG_LINES)), "{e}"); +} + +#[test] +fn between_slices_every_change_is_refused_and_readers_see_the_committed_art() { + let mut s = two_docs(); + let before = state(&s); + paused(&mut s, &format!("{} while (true) {{}}", two_rect_script())); + assert_eq!(state(&s), before); + for (id, p) in [ + ("shape.rectangle", rect(1)), + ("clipboard.importSvg", json!({})), + ("clipboard.importImage", json!({})), + ("clipboard.importText", json!({})), + ("clipboard.importPdf", json!({})), + ("clipboard.importEmf", json!({})), + ("clipboard.importEps", json!({})), + ("edit.undo", json!({})), + ("file.new", json!({})), + ("file.close", json!({})), + ("file.revert", json!({})), + ("file.recovery.restore", json!({})), + ("action.batch", json!({})), + ("command.batch", json!({"commands": []})), + ("effect.apply", json!({})), + ("script.run", json!({"source": "1"})), + ] { + assert!(matches!(s.execute(id, &p), Err(EngineError::Busy)), "{id}"); + } + assert!(matches!(s.begin_interaction("x"), Err(EngineError::Busy))); + assert!(matches!(s.edit("x", |_, _| Ok(())), Err(EngineError::Busy))); + assert!(matches!(s.preview("shape.rectangle", &rect(1)), Err(EngineError::Busy))); + let press = tools::PointerEvent { + kind: tools::PointerKind::Down, + ..serde_json::from_value(json!({"kind": "move", "pos": {"x": 0.0, "y": 0.0}})).unwrap() + }; + assert!(matches!(s.pointer(&press, ViewInfo::default()), Err(EngineError::Busy))); + let inspected = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspected["layers"][0]["children"].as_array().map(Vec::len), Some(1), "{inspected}"); + assert_eq!(state(&s), before, "the refused commands changed nothing"); + s.script_abandon(); + assert_eq!(state(&s), before); + assert!(s.execute("shape.rectangle", &rect(1)).unwrap()["id"].is_u64()); +} + +#[test] +fn a_document_closed_or_opened_in_a_run_follows_the_run() { + let closing = "vc.run('file.close', {index: 0}); vc.run('file.new', {}); vc.run('shape.rectangle', {x:1,y:1,width:1,height:1});"; + let mut s = two_docs(); + let before = state(&s); + assert!(run(&mut s, &format!("{closing} throw new Error('x');")).is_err()); + assert_eq!(state(&s), before); + let first = s.documents()[0].uid; + run(&mut s, closing).unwrap(); + assert_eq!(s.documents().len(), 2); + assert!(s.documents().iter().all(|d| d.uid != first)); + assert_eq!(s.documents()[1].history.undo.len(), 0, "a document the script made starts with no undo steps"); +} + +#[test] +fn undo_in_a_run_reaches_only_the_runs_own_steps_and_old_ids_stay_dead() { + let mut s = two_docs(); + let history = s.documents()[1].history.undo.len(); + let source = "var p = vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}).id;\ + vc.run('edit.undo');\ + var q = vc.run('shape.rectangle', {x:2,y:2,width:1,height:1}).id;\ + var why = '';\ + try { vc.run('edit.clear', {ids: [p]}); } catch (e) { why = e.message; }\ + vc.run('edit.undo');\ + try { vc.run('edit.undo'); why += '|undid a step from before the run'; } catch (e) { why += '|stopped'; }\ + vc.log({same: p === q, why: why});\ + vc.run('shape.rectangle', {x:3,y:3,width:1,height:1});"; + let r = run(&mut s, source).unwrap(); + assert_eq!(r["log"][0]["same"], false, "{r}"); + let why = r["log"][0]["why"].as_str().unwrap(); + assert!(why.ends_with("|stopped") && why.len() > "|stopped".len(), "{why}"); + assert_eq!(objects(&s, 1), 2); + assert_eq!(s.documents()[1].history.undo.len(), history + 1); +} + +#[test] +fn a_string_over_the_crossing_limit_does_not_cross() { + let limits = vectorcraft_js::DataLimits { bytes: 20, ..vectorcraft_js::DataLimits::default() }; + assert!(script::to_data(&json!("four"), &limits, 0, &mut (0, 0)).is_ok()); + assert_eq!(script::to_data(&json!("fives"), &limits, 0, &mut (0, 0)), Err(String::new())); +} + +#[test] +fn a_result_too_large_to_cross_names_its_place() { + let limits = vectorcraft_js::DataLimits { bytes: 90, ..vectorcraft_js::DataLimits::default() }; + let v = json!({"a": [1, {"name": "x".repeat(40)}]}); + assert_eq!(script::to_data(&v, &limits, 0, &mut (0, 0)), Err(".a[1].name".to_string())); +} + +#[test] +fn ids_given_out_in_an_abandoned_run_are_not_given_out_again() { + let mut s = two_docs(); + let before = s.active().unwrap().doc.peek_next_id(); + paused(&mut s, &format!("{} while (true) {{}}", two_rect_script())); + assert!(s.script_abandon()); + assert!(s.active().unwrap().doc.peek_next_id() > before); +} + +#[test] +fn ids_given_out_in_a_failed_run_are_not_given_out_again() { + let mut s = two_docs(); + let e = + run(&mut s, "var p = vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}).id; throw new Error('id=' + p + ';');").unwrap_err().to_string(); + let used: u64 = e.split("id=").nth(1).unwrap().split(';').next().unwrap().parse().unwrap(); + let dirty = s.documents()[1].is_dirty(); + let id = s.execute("shape.rectangle", &rect(3)).unwrap()["id"].as_u64().unwrap(); + assert!(id > used, "{id} after {used}"); + assert!(dirty); +} + +#[test] +fn a_rollback_keeps_the_history_after_an_undo_in_the_run() { + let mut s = two_docs(); + let before = state(&s); + let source = "vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); vc.run('edit.undo'); throw new Error('x');"; + assert!(run(&mut s, source).is_err()); + assert_eq!(state(&s), before); +} + +#[test] +fn a_batch_joins_the_runs_one_step() { + let mut s = two_docs(); + let undo = s.documents()[1].history.undo.len(); + let source = "vc.run('command.batch', {commands: [\ + {command:'shape.rectangle', params:{x:1,y:1,width:1,height:1}},\ + {command:'shape.rectangle', params:{x:2,y:2,width:1,height:1}}]});"; + run(&mut s, source).unwrap(); + assert_eq!(s.documents()[1].history.undo.len(), undo + 1); + assert_eq!(objects(&s, 1), 3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s, 1), 1); +} + +#[test] +fn a_run_with_nobody_to_ask_ends_at_its_growth_allowance() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let mut s = two_docs(); + let before = state(&s); + let source = "try { while (true) vc.run('shape.rectangle', {x:1,y:1,width:1,height:1}); } catch (e) { vc.run('shape.rectangle', {x:2,y:1,width:1,height:1}); }"; + let e = run(&mut s, source).unwrap_err().to_string(); + TEST_GROWTH.with(|g| g.set(None)); + assert!(e.contains("objects and data"), "{e}"); + assert_eq!(state(&s), before); +} + +#[test] +fn a_person_can_let_a_growing_run_go_on_or_stop_it() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let run_to_the_question = |s: &mut Session| { + s.script_frames = true; + run(s, "for (var i = 0; i < 25; i++) vc.run('shape.rectangle', {x:i,y:1,width:1,height:1});").unwrap(); + let slices = Cell::new(0); + while !s.script_waiting() { + assert!(s.script_pump().is_none()); + slices.set(slices.get() + 1); + assert!(slices.get() < 1_000); + } + // Waiting takes no fuel, however often the frame comes round. + assert!(s.script_pump().is_none()); + }; + let mut s = two_docs(); + run_to_the_question(&mut s); + s.script_continue(); + assert!(!s.script_waiting()); + let done = loop { + if let Some(r) = s.script_pump() { + break r; + } + }; + assert_eq!(done.unwrap()["ok"], true); + assert_eq!(objects(&s, 1), 26); + + let mut s = two_docs(); + let before = state(&s); + run_to_the_question(&mut s); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert!(!s.script_waiting()); + assert!(s.script_pump().unwrap().is_err()); + TEST_GROWTH.with(|g| g.set(None)); + assert_eq!(state(&s), before); +} + +#[test] +fn a_file_saved_before_a_failure_stays_written_and_is_named() { + let path = std::env::temp_dir().join(format!("vc-script-save-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = two_docs(); + let before = state(&s); + let source = + format!("vc.run('file.saveAs', {{path: {p:?}}}); vc.run('shape.rectangle', {{x:1,y:1,width:1,height:1}}); throw new Error('later');"); + let e = run(&mut s, &source).unwrap_err().to_string(); + assert!(path.exists()); + assert!(e.contains(&p), "{e}"); + assert_eq!(s.script_report().unwrap()["written"], json!([p])); + assert_eq!(state(&s), before, "the document does not keep the new path"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_committed_run_exports_what_it_drew() { + let path = std::env::temp_dir().join(format!("vc-script-export-{}.svg", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = path.to_string_lossy().into_owned(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = "for (var i = 0; i < 7; i++) vc.run('shape.rectangle', {x:i*20,y:5,width:10,height:10});"; + run(&mut s, source).unwrap(); + s.execute("document.save", &json!({"path": p})).unwrap(); + let svg = std::fs::read_to_string(&path).unwrap(); + assert_eq!(svg.matches(" Result> { + if self.script_running() { + return if ev.kind == PointerKind::Move { Ok(vec![]) } else { Err(EngineError::Busy) }; + } let n = self.next_input(); if ev.kind != PointerKind::Move && (!self.held_input.is_empty() || self.tool_edit_pending()) { self.held_input.push_back((n, HeldInput::Pointer(*ev), view)); @@ -877,6 +880,9 @@ impl Session { /// A key for the active tool. While the tool computes an edit on another thread, the key is held /// and made once the edit is applied, after the input held before it. pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result> { + if self.script_running() { + return Err(EngineError::Busy); + } let n = self.next_input(); if self.tool_editing() { self.held_input.push_back((n, HeldInput::Key(key, mods), view)); @@ -910,6 +916,9 @@ impl Session { /// Typed text for the active tool (Type tool). pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result> { + if self.script_running() { + return Err(EngineError::Busy); + } let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text)); self.take_tool_panic()?; self.apply_actions(acts) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 984d3a123..d420b94b3 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -501,7 +501,7 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> return Ok(Value::String(Str::from_units(units.to_vec()))); } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), - Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Host { name, .. } | Func::Data { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Bound { .. }) => "function () { [native code] }".to_string(), None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), }; diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 1b2099fe1..a53721fc7 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -158,6 +158,9 @@ pub(crate) type NativeFn = fn(&mut crate::interp::Interp, Value, &[Value], bool) /// A function the embedding defines. pub(crate) type HostFn = dyn Fn(&[crate::JsValue]) -> Result; +/// A function the embedding defines that takes and gives structured values. +pub(crate) type DataFn = crate::host::HostFunction; + /// The built-ins the interpreter treats specially when it calls them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum NativeKind { @@ -177,6 +180,7 @@ pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, + Data { f: DataFn, name: Rc }, Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs new file mode 100644 index 000000000..457b2ce2e --- /dev/null +++ b/crates/js/src/host.rs @@ -0,0 +1,351 @@ +//! Structured values between a script and its host: [`Data`] (JSON's shapes and `undefined`), the +//! checks a value passes on its way across, and the frozen objects a host adds to the global +//! environment. +//! +//! A value read from a script may hold plain objects, arrays, strings, finite numbers, booleans, +//! `null` and `undefined`. A cycle, a value too deep or too large, a function, a getter, a date, a +//! regular expression, a boxed primitive, a hole in an array and a number that is not finite are +//! refused with a `TypeError` or `RangeError` naming where the value is. The same object may appear +//! more than once when it is not its own ancestor; each appearance counts against the size limit, +//! so sharing cannot multiply the work without limit. + +use std::rc::Rc; + +use crate::LimitKind; +use crate::heap::{Class, ENUMERABLE, Func, Key, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::{Value, index_key}; + +/// A value that crosses between a script and the host. +#[derive(Clone, Debug, PartialEq)] +pub enum Data { + Undefined, + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + /// An object's own enumerable properties, in the order the object lists them. + Object(Vec<(String, Data)>), +} + +/// How much one crossing may carry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DataLimits { + /// How deeply arrays and objects may nest. + pub depth: usize, + /// Estimated bytes of the strings, names and numbers. + pub bytes: usize, + /// Array elements and object properties, all of them counted together. + pub entries: usize, +} + +impl Default for DataLimits { + fn default() -> Self { + Self { depth: 64, bytes: 16 << 20, entries: 1 << 20 } + } +} + +/// How a host function fails. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostError { + /// Thrown to the script as an `Error`; the script can catch it. + Error(String), + /// Thrown to the script as a `TypeError`. + Type(String), + /// Thrown to the script as a `RangeError`. + Range(String), + /// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it. + Abort(String), +} + +/// A function the host gives a script, with its arguments converted. +pub type HostFunction = Rc) -> Result>; + +/// A member of an object the host adds to the global environment. +#[derive(Clone)] +pub enum HostItem { + /// A read-only copy of a value. + Value(Data), + Function(HostFunction), + /// A property whose value the function gives each time it is read. + Getter(HostFunction), + /// A frozen object with these members. + Object(Vec<(String, HostItem)>), +} + +enum Step { + Name(Key), + Index(u32), +} + +/// A value being read: what it has used so far, and where the reader is. +struct Reading { + limits: DataLimits, + bytes: usize, + entries: usize, + ancestors: Vec, + path: Vec, + function: Rc, +} + +impl Reading { + fn place(&self) -> String { + let mut s = String::new(); + for step in &self.path { + match step { + Step::Index(i) => s.push_str(&format!("[{i}]")), + Step::Name(k) => { + s.push('.'); + s.push_str(&String::from_utf16_lossy(k)); + } + } + } + s + } +} + +enum Shape { + Array(u32), + Plain(Vec<(Key, Prop)>), +} + +impl Interp { + fn data_error(&mut self, r: &Reading, t: ErrorType, what: &str) -> Abrupt { + let message = format!("{}: argument{} {what}", r.function, r.place()); + self.throw(t, &message) + } + + fn charge_reading(&mut self, r: &mut Reading, bytes: usize) -> JsResult<()> { + r.bytes = r.bytes.saturating_add(bytes); + if r.bytes > r.limits.bytes { + return Err(self.data_error(r, ErrorType::Range, "is too large")); + } + Ok(()) + } + + fn charge_entries(&mut self, r: &mut Reading, n: usize) -> JsResult<()> { + r.entries = r.entries.saturating_add(n); + if r.entries > r.limits.entries { + return Err(self.data_error(r, ErrorType::Range, "has too many entries")); + } + Ok(()) + } + + fn read_data(&mut self, v: &Value, r: &mut Reading) -> JsResult { + match v { + Value::Undefined => Ok(Data::Undefined), + Value::Null => Ok(Data::Null), + Value::Bool(b) => Ok(Data::Bool(*b)), + Value::Number(n) if n.is_finite() => Ok(Data::Number(*n)), + Value::Number(_) => Err(self.data_error(r, ErrorType::Type, "is not a finite number")), + Value::String(s) => { + self.charge_reading(r, s.len().saturating_mul(3).saturating_add(2))?; + Ok(Data::String(s.to_rust_string())) + } + Value::Object(id) => self.read_object(*id, r), + } + } + + fn read_object(&mut self, id: ObjId, r: &mut Reading) -> JsResult { + self.use_fuel()?; + if r.ancestors.contains(&id) { + return Err(self.data_error(r, ErrorType::Type, "refers to itself")); + } + if r.ancestors.len() >= r.limits.depth { + return Err(self.data_error(r, ErrorType::Range, "is nested too deep")); + } + let shape = match &self.object(id)?.class { + Class::Array { length, .. } => Shape::Array(*length), + Class::Ordinary => { + let o = self.object(id)?; + if o.props.len() > r.limits.entries { + return Err(self.data_error(r, ErrorType::Range, "has too many entries")); + } + Shape::Plain(o.props.iter().filter(|(_, p)| p.enumerable()).map(|(k, p)| (k.clone(), p.clone())).collect()) + } + other => { + let what = format!("is a {}, not plain data", other.name()); + return Err(self.data_error(r, ErrorType::Type, &what)); + } + }; + r.ancestors.push(id); + let out = match shape { + Shape::Array(length) => self.read_array(id, length, r), + Shape::Plain(props) => self.read_plain(props, r), + }; + r.ancestors.pop(); + out + } + + fn read_array(&mut self, id: ObjId, length: u32, r: &mut Reading) -> JsResult { + self.charge_entries(r, length as usize)?; + let mut items = Vec::with_capacity(length as usize); + for i in 0..length { + self.use_fuel()?; + r.path.push(Step::Index(i)); + let prop = self.object(id)?.props.get(&index_key(i)).cloned(); + let item = match prop { + Some(Prop::Data { value: Value::Undefined, .. }) | None => Err(self.data_error(r, ErrorType::Type, "is undefined")), + Some(Prop::Data { value, .. }) => self.read_data(&value, r), + Some(Prop::Accessor { .. }) => Err(self.data_error(r, ErrorType::Type, "is a getter, not plain data")), + }?; + r.path.pop(); + items.push(item); + } + Ok(Data::Array(items)) + } + + fn read_plain(&mut self, props: Vec<(Key, Prop)>, r: &mut Reading) -> JsResult { + self.charge_entries(r, props.len())?; + let mut out = Vec::with_capacity(props.len()); + for (k, prop) in props { + self.use_fuel()?; + self.charge_reading(r, k.len().saturating_mul(3).saturating_add(8))?; + let name = String::from_utf16_lossy(&k); + r.path.push(Step::Name(k)); + let item = match prop { + // A property holding `undefined` reads as one left out. + Prop::Data { value: Value::Undefined, .. } => None, + Prop::Data { value, .. } => Some(self.read_data(&value, r)?), + Prop::Accessor { .. } => return Err(self.data_error(r, ErrorType::Type, "is a getter, not plain data")), + }; + r.path.pop(); + if let Some(item) = item { + out.push((name, item)); + } + } + Ok(Data::Object(out)) + } + + /// A host value as a JavaScript value, `frozen` when a script may not change it. A value + /// deeper or longer than the limits throws a `RangeError`. + pub(crate) fn write_data(&mut self, d: Data, depth: usize, frozen: bool) -> JsResult { + if depth > self.data_limits.depth { + return Err(self.throw(ErrorType::Range, "a host value is nested too deep")); + } + Ok(match d { + Data::Undefined => Value::Undefined, + Data::Null => Value::Null, + Data::Bool(b) => Value::Bool(b), + Data::Number(n) => Value::Number(n), + Data::String(s) => { + let bytes = string_bytes(s.encode_utf16().count()); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + Value::str(&s) + } + Data::Array(items) => { + self.write_container(items.into_iter().enumerate().map(|(i, d)| (Slot::Index(i), d)).collect(), true, depth, frozen)? + } + Data::Object(items) => self.write_container(items.into_iter().map(|(k, d)| (Slot::Name(k), d)).collect(), false, depth, frozen)?, + }) + } + + fn write_container(&mut self, items: Vec<(Slot, Data)>, array: bool, depth: usize, frozen: bool) -> JsResult { + if items.len() > self.data_limits.entries { + return Err(self.throw(ErrorType::Range, "a host value has too many entries")); + } + let flags = if frozen { ENUMERABLE } else { crate::heap::OPEN }; + self.reserve_heap(OBJECT_BYTES.saturating_add(PROP_BYTES.saturating_mul(items.len())))?; + let id = if array { + let proto = self.intrinsics.array_proto; + self.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: !frozen }, Some(proto))) + } else { + self.new_object() + }; + let mark = self.temp_roots.len(); + self.temp_roots.push(Value::Object(id)); + let mut result = Ok(()); + for (slot, d) in items { + let name = match slot { + Slot::Index(i) => index_key(u32::try_from(i).unwrap_or(u32::MAX)), + Slot::Name(n) => key(&n), + }; + result = self.write_data(d, depth + 1, frozen).and_then(|value| self.define(id, name, Prop::Data { value, flags })); + if result.is_err() { + break; + } + } + self.temp_roots.truncate(mark); + result?; + if frozen { + self.object_mut(id)?.extensible = false; + } + Ok(Value::Object(id)) + } + + /// Calls host function `f`: reads its arguments, runs it, and gives its result to the script. + pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value]) -> JsResult { + let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() }; + let mut data = Vec::with_capacity(args.len()); + for (i, a) in args.iter().enumerate() { + reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); + data.push(self.read_data(a, &mut reading)?); + reading.path.pop(); + } + match f(data) { + Ok(d) => self.write_data(d, 0, false), + Err(HostError::Abort(message)) => { + self.host_abort = Some(message); + Err(Abrupt::Limit(LimitKind::Host)) + } + Err(HostError::Error(message)) => Err(self.throw_host(ErrorType::Error, &message)?), + Err(HostError::Type(message)) => Err(self.throw_host(ErrorType::Type, &message)?), + Err(HostError::Range(message)) => Err(self.throw_host(ErrorType::Range, &message)?), + } + } + + /// The exception for a host error, charged to the heap. + fn throw_host(&mut self, t: ErrorType, message: &str) -> JsResult { + let bytes = string_bytes(message.encode_utf16().count()); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + Ok(self.throw(t, message)) + } + + /// Adds the frozen object `name` with `items` to the global environment. + pub(crate) fn install_host_object(&mut self, name: &str, items: Vec<(String, HostItem)>) -> JsResult<()> { + let o = self.host_object(name, items)?; + let global = self.global; + self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: 0 }) + } + + fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { + let o = self.new_object(); + let mark = self.temp_roots.len(); + self.temp_roots.push(Value::Object(o)); + let mut result = Ok(()); + for (name, item) in items { + result = self.install_item(o, path, name, item); + if result.is_err() { + break; + } + } + self.temp_roots.truncate(mark); + result?; + self.object_mut(o)?.extensible = false; + Ok(o) + } + + fn install_item(&mut self, o: ObjId, path: &str, name: String, item: HostItem) -> JsResult<()> { + let full = format!("{path}.{name}"); + let prop = match item { + HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, + HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full)), flags: ENUMERABLE }, + HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full)), set: None, flags: ENUMERABLE }, + HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE }, + }; + self.define(o, key(&name), prop) + } + + fn host_function(&mut self, f: HostFunction, name: &str) -> ObjId { + let proto = self.intrinsics.function_proto; + self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into() }), Some(proto))) + } +} + +enum Slot { + Index(usize), + Name(String), +} diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs new file mode 100644 index 000000000..d43e26f5b --- /dev/null +++ b/crates/js/src/host_tests.rs @@ -0,0 +1,277 @@ +//! Resumable runs and the structured values host functions take and give. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use super::*; + +fn realm(fuel: u64) -> Realm { + let mut r = Realm::new(); + r.set_limits(Limits { fuel, ..Limits::default() }); + r +} + +/// A realm with `host.take(value)`, which records what it was given and returns it. +fn with_take(r: &mut Realm) -> Rc>> { + let seen: Rc>> = Rc::default(); + let log = seen.clone(); + let f: HostFunction = Rc::new(move |mut args| { + let v = if args.is_empty() { Data::Undefined } else { args.remove(0) }; + log.borrow_mut().push(v.clone()); + Ok(v) + }); + r.define_host_object("host", vec![("take".into(), HostItem::Function(f))]).unwrap(); + seen +} + +fn finish(r: &mut Realm, slice: u64) -> (Result, u32) { + let mut slices = 0; + loop { + slices += 1; + match r.advance(slice) { + Ok(Progress::Paused) => {} + Ok(Progress::Done(v)) => return (Ok(v), slices), + Err(e) => return (Err(e), slices), + } + } +} + +#[test] +fn a_resumed_run_gives_the_result_of_one_run() { + let source = "var t = 0; for (var i = 0; i < 2000; i++) { t += i; } t"; + let whole = realm(10_000_000).run(source).unwrap(); + let mut r = realm(10_000_000); + r.start(source).unwrap(); + assert!(r.is_running()); + let (v, slices) = finish(&mut r, 100); + assert_eq!(v.unwrap(), whole); + assert!(slices > 10, "the run paused: {slices} slices"); + assert!(!r.is_running()); +} + +#[test] +fn state_survives_a_pause_and_a_collection_between_slices() { + let mut r = realm(10_000_000); + r.start("var o = {a: [1, 2, 3]}; var n = 0; for (var i = 0; i < 500; i++) { n += o.a.length; } n").unwrap(); + assert_eq!(r.advance(50).unwrap(), Progress::Paused); + r.collect_garbage(); + assert_eq!(finish(&mut r, 50).0.unwrap(), JsValue::Number(1500.0)); +} + +#[test] +fn the_fuel_limit_covers_every_slice_together() { + let mut r = realm(5_000); + r.start("while (true) {}").unwrap(); + let (v, slices) = finish(&mut r, 100); + assert!(matches!(v, Err(RunError { kind: RunErrorKind::Limit(LimitKind::Fuel), .. }))); + assert!((40..=60).contains(&slices), "{slices}"); +} + +#[test] +fn a_set_flag_stops_a_loop_in_a_getter() { + let mut r = realm(u64::MAX); + r.start("var o = {}; Object.defineProperty(o, 'x', {get: function () { while (true) {} }}); o.x").unwrap(); + r.interrupt().set(); + let e = r.advance(10).unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Interrupted)); +} + +#[test] +fn a_nested_run_past_the_slice_allowance_is_the_unbroken_limit_and_a_run_with_little_fuel_is_the_fuel_limit() { + for (total, kind) in [(u64::MAX, LimitKind::Unbroken), (1_000, LimitKind::Fuel)] { + for source in [ + "var o = {}; Object.defineProperty(o, 'x', {get: function () { while (true) {} }}); o.x", + "[1].forEach(function () { while (true) {} });", + ] { + let mut r = realm(total); + r.set_unbroken_fuel(5_000); + r.start(source).unwrap(); + let e = finish(&mut r, 1).0.unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(kind), "{source}"); + } + } +} + +#[test] +fn neither_a_limit_nor_a_stop_can_be_caught() { + let mut r = realm(1_000); + r.start("try { while (true) {} } catch (e) { 'caught' } finally { 'ran' }").unwrap(); + assert_eq!(finish(&mut r, 50).0.unwrap_err().kind, RunErrorKind::Limit(LimitKind::Fuel)); + + let shared = Interrupt::new(); + let mut r = realm(1_000_000); + r.share_interrupt(&shared); + r.start("try { while (true) {} } catch (e) { 'caught' }").unwrap(); + assert_eq!(r.advance(10).unwrap(), Progress::Paused); + shared.set(); + assert_eq!(r.advance(10).unwrap_err().kind, RunErrorKind::Limit(LimitKind::Interrupted)); +} + +#[test] +fn a_host_function_can_end_the_slice() { + let mut r = realm(1_000_000); + let pause = r.pause_flag(); + let f: HostFunction = Rc::new(move |_| { + pause.set(true); + Ok(Data::Null) + }); + r.define_host_object("host", vec![("yield".into(), HostItem::Function(f))]).unwrap(); + r.start("host.yield(); host.yield(); 7").unwrap(); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert_eq!(r.advance(1_000).unwrap(), Progress::Paused); + assert_eq!(r.advance(1_000).unwrap(), Progress::Done(JsValue::Number(7.0))); +} + +#[test] +fn plain_values_cross_in_both_directions() { + let mut r = realm(1_000_000); + let seen = with_take(&mut r); + let out = r.run("var v = host.take({a: 1, b: 'x', c: [true, null], d: undefined}); v.c[0] === true && v.b === 'x' && v.d === undefined"); + assert_eq!(out.unwrap(), JsValue::Bool(true)); + let want = Data::Object(vec![ + ("a".into(), Data::Number(1.0)), + ("b".into(), Data::String("x".into())), + ("c".into(), Data::Array(vec![Data::Bool(true), Data::Null])), + ]); + assert_eq!(seen.borrow().as_slice(), [want]); +} + +#[test] +fn a_callback_of_a_built_in_can_call_the_host() { + let mut r = realm(1_000_000); + let seen = with_take(&mut r); + r.start("[1, 2, 3].forEach(function (n) { host.take(n); });").unwrap(); + finish(&mut r, 2).0.unwrap(); + assert_eq!(seen.borrow().len(), 3); +} + +/// `value` passed to the host throws an error of type `name` whose message holds `part`. +fn refused(setup: &str, name: &str, part: &str) { + let mut r = realm(10_000_000); + r.set_data_limits(DataLimits { depth: 8, bytes: 1_000, entries: 100 }); + let seen = with_take(&mut r); + let probe = format!("var e; {setup} try {{ host.take(value); }} catch (x) {{ e = x; }} e ? e.name + '|' + e.message : 'none'"); + let JsValue::String(out) = r.run(&probe).unwrap() else { panic!("{setup}") }; + assert!(out.starts_with(name) && out.contains(part), "{setup}: {out}"); + assert!(seen.borrow().is_empty(), "{setup}: the function ran"); +} + +#[test] +fn values_that_are_not_plain_data_are_refused_with_their_place() { + refused("var value = {}; value.self = value;", "TypeError", ".self refers to itself"); + refused("var value = [[1]]; value[0][0] = value;", "TypeError", "[0][0][0] refers to itself"); + refused("var value = {f: function () {}};", "TypeError", ".f is a Function"); + refused("var value = {n: 1 / 0};", "TypeError", ".n is not a finite number"); + refused("var value = {n: 0 / 0};", "TypeError", ".n is not a finite number"); + refused("var value = {d: new Date(0)};", "TypeError", ".d is a Date"); + refused("var value = {}; Object.defineProperty(value, 'g', {enumerable: true, get: function () { return 1; }});", "TypeError", ".g is a getter"); + refused("var value = [1, , 3];", "TypeError", "[0][1] is undefined"); + refused("var value = 1; for (var i = 0; i < 20; i++) value = [value];", "RangeError", "nested too deep"); + refused("var value = new Array(5000);", "RangeError", "too many entries"); + refused("var value = 'x'; for (var i = 0; i < 12; i++) value += value;", "RangeError", "too large"); +} + +#[test] +fn a_shared_object_is_allowed_and_sharing_cannot_multiply_the_work() { + let mut r = realm(10_000_000); + r.set_data_limits(DataLimits { depth: 64, bytes: 16 << 20, entries: 1_000 }); + let seen = with_take(&mut r); + r.run("var s = {a: 1}; host.take({x: s, y: s, z: [s, s]});").unwrap(); + assert_eq!(seen.borrow().len(), 1); + // Each level doubles the paths to the bottom: the entry limit ends the walk. + let JsValue::String(out) = + r.run("var v = [1]; for (var i = 0; i < 40; i++) v = [v, v]; var m = 'none'; try { host.take(v); } catch (e) { m = e.name; } m").unwrap() + else { + panic!() + }; + assert_eq!(out, "RangeError"); +} + +#[test] +fn a_host_object_cannot_be_changed_by_a_script() { + let mut r = realm(1_000_000); + let args = Data::Object(vec![("list".into(), Data::Array(vec![Data::Number(1.0)]))]); + r.define_host_object("vc", vec![("args".into(), HostItem::Value(args))]).unwrap(); + let out = r.run( + "vc.args.list[0] = 2; vc.args.list.push; vc.args.more = 1; vc.extra = 1; var was = vc; vc = 3; \ + [was.args.list[0], was.args.more === undefined, was.extra === undefined, Object.isFrozen(was.args.list)].join()", + ); + assert_eq!(out.unwrap(), JsValue::String("1,true,true,true".into())); +} + +#[test] +fn a_getter_is_read_each_time() { + let mut r = realm(1_000_000); + let count = Rc::new(Cell::new(0.0)); + let c = count.clone(); + let f: HostFunction = Rc::new(move |_| { + c.set(c.get() + 1.0); + Ok(Data::Number(c.get())) + }); + r.define_host_object("host", vec![("n".into(), HostItem::Getter(f))]).unwrap(); + assert_eq!(r.run("host.n + host.n").unwrap(), JsValue::Number(3.0)); +} + +#[test] +fn errors_from_the_host_can_be_caught_and_an_abort_cannot() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|a| match a.first() { + Some(Data::String(k)) if k == "type" => Err(HostError::Type("bad type".into())), + Some(Data::String(k)) if k == "range" => Err(HostError::Range("bad range".into())), + Some(Data::String(k)) if k == "abort" => Err(HostError::Abort("stopped by the host".into())), + _ => Err(HostError::Error("bad".into())), + }); + r.define_host_object("host", vec![("f".into(), HostItem::Function(f))]).unwrap(); + let out = r.run( + "var a = []; ['type', 'range', 'x'].forEach(function (k) { try { host.f(k); } catch (e) { a.push(e.name + ':' + e.message); } }); a.join()", + ); + assert_eq!(out.unwrap(), JsValue::String("TypeError:bad type,RangeError:bad range,Error:bad".into())); + let e = r.run("try { host.f('abort'); } catch (e) { 'caught' }").unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped by the host")); +} + +#[test] +fn an_error_says_where_in_the_source_it_was_raised() { + let mut r = realm(1_000_000); + r.start("var a = 1;\n throw new TypeError('no');").unwrap(); + let e = finish(&mut r, 1).0.unwrap_err(); + assert_eq!((e.kind, e.line), (RunErrorKind::Thrown, 2)); + assert!(e.column >= 3, "{}", e.column); + assert_eq!(e.message, "TypeError: no"); +} + +#[test] +fn advancing_without_a_run_is_an_error() { + let mut r = realm(1_000); + let e = r.advance(10).unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "no run to advance")); +} + +proptest::proptest! { + /// A slice size does not change what a script computes. + #[test] + fn any_slice_size_gives_the_same_result(slice in 1u64..500, n in 0u32..300) { + let source = format!("var a = []; for (var i = 0; i < {n}; i++) a.push(i * 2); a.join().length"); + let whole = realm(50_000_000).run(&source).unwrap(); + let mut r = realm(50_000_000); + r.start(&source).unwrap(); + proptest::prop_assert_eq!(finish(&mut r, slice).0.unwrap(), whole); + } + + /// Hostile text for a value never makes the bridge panic. + #[test] + fn hostile_values_end_with_a_value_or_a_script_error(depth in 0usize..120, width in 0usize..6, cycle in proptest::bool::ANY) { + let mut r = realm(5_000_000); + with_take(&mut r); + let mut s = String::from("var v = [];"); + for _ in 0..depth { + s.push_str(&format!("v = [v{}];", ",v".repeat(width.min(2)))); + } + if cycle { + s.push_str("var c = {}; c.c = c; v = [v, c];"); + } + s.push_str("var ok = 'ok'; try { host.take(v); } catch (e) { ok = e.name; } ok"); + let JsValue::String(out) = r.run(&s).unwrap() else { panic!() }; + proptest::prop_assert!(matches!(out.as_str(), "ok" | "RangeError" | "TypeError"), "{}", out); + } +} diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b110a955b..f86814949 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -179,6 +179,12 @@ pub(crate) struct Interp { pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc, + /// Set by the host to end the current slice of a resumable run at the next safe point. + pub(crate) pause: Rc>, + /// The message of the uncatchable error a host function ended the run with. + pub(crate) host_abort: Option, + /// How much a value passed to or from a host function may carry. + pub(crate) data_limits: crate::DataLimits, } /// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's @@ -367,6 +373,9 @@ impl Interp { install_error: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + pause: Rc::default(), + host_abort: None, + data_limits: crate::DataLimits::default(), }; crate::builtins::install(&mut interp); interp.fuel = limits.fuel; @@ -558,6 +567,28 @@ impl Interp { } } + /// Runs the boundary frame on top until it returns (`Some` with its value) or the remaining + /// fuel falls to `pause_at`, or the host asks for a pause (`None`, with the frames left in + /// place for the next call). Only this outermost loop pauses: a nested run has native frames + /// under it and goes on until it returns. + pub(crate) fn execute_slice(&mut self, pause_at: u64) -> JsResult> { + loop { + match self.step() { + Ok(Some(v)) => return Ok(Some(v)), + Ok(None) => {} + Err(Abrupt::Throw(v)) => self.unwind(v)?, + Err(other) => { + self.note_site(); + self.abort_to_boundary(); + return Err(other); + } + } + if self.fuel <= pause_at || self.pause.replace(false) { + return Ok(None); + } + } + } + fn step(&mut self) -> JsResult> { self.tick()?; let frame = self.frame_mut()?; @@ -966,11 +997,11 @@ impl Interp { // ---- properties ---- - fn object(&self, id: ObjId) -> JsResult<&Object> { + pub(crate) fn object(&self, id: ObjId) -> JsResult<&Object> { self.heap.object(id).ok_or_else(|| Abrupt::Internal("object out of range".into())) } - fn object_mut(&mut self, id: ObjId) -> JsResult<&mut Object> { + pub(crate) fn object_mut(&mut self, id: ObjId) -> JsResult<&mut Object> { self.heap.object_mut(id).ok_or_else(|| Abrupt::Internal("object out of range".into())) } @@ -1815,6 +1846,11 @@ impl Interp { self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } + Func::Data { f, name } => { + // The arguments are read before the function runs, and a result being built + // keeps itself reachable. + return Ok(Prepared::Done(self.call_data(&f, &name, &args)?)); + } Func::Host { f: host, .. } => { let js_args: Vec = args.iter().map(|v| self.to_js_value(v)).collect(); return match host(&js_args) { @@ -2135,6 +2171,15 @@ impl Interp { if is_eval && self.reentry >= self.limits.reentry { return Err(Abrupt::Limit(LimitKind::Reentry)); } + self.enter_global(code, is_eval)?; + self.reentry += nested; + let r = self.execute(); + self.reentry -= nested; + r + } + + /// Declares `code`'s variables and functions and pushes its boundary frame, ready to run. + pub(crate) fn enter_global(&mut self, code: Rc, is_eval: bool) -> JsResult<()> { self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let (env, global) = (self.global_env, self.global); let (env, var_env) = if code.strict && is_eval { @@ -2144,11 +2189,7 @@ impl Interp { (env, env) }; self.instantiate_global(&code, var_env, is_eval)?; - self.push_code_frame(code, env, var_env, Value::Object(global), true)?; - self.reentry += nested; - let r = self.execute(); - self.reentry -= nested; - r + self.push_code_frame(code, env, var_env, Value::Object(global), true) } fn push_code_frame(&mut self, code: Rc, env: EnvId, var_env: EnvId, this: Value, boundary: bool) -> JsResult<()> { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index b883be432..a95a4b884 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -47,6 +47,7 @@ mod compiler; mod date_builtins; mod error; mod heap; +mod host; mod interp; mod lexer; mod number; @@ -65,9 +66,10 @@ pub use ast::{ PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, }; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; +pub use host::{Data, DataLimits, HostError, HostFunction, HostItem}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; -pub use realm::{Interrupt, JsValue, LimitKind, Limits, Realm, RunError, RunErrorKind}; +pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind}; pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; /// How a script is read. @@ -185,6 +187,8 @@ impl LineTable { #[cfg(test)] mod builtins_tests; #[cfg(test)] +mod host_tests; +#[cfg(test)] mod interp_tests; #[cfg(test)] mod tests; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 45d7c08bd..a28b150a5 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -51,14 +51,36 @@ pub enum LimitKind { Reentry, /// The host set the interrupt flag. Interrupted, + /// A getter, a callback of a built-in or another nested run spent [`MAX_UNBROKEN_FUEL`] + /// without returning to the point where a resumable run can pause. + Unbroken, + /// A host function ended the run ([`crate::HostError::Abort`]). + Host, +} + +/// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run +/// it cannot pause. +pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000; + +/// How far a resumable run got. +#[derive(Clone, Debug, PartialEq)] +pub enum Progress { + /// The slice ended; call [`Realm::advance`] again. + Paused, + /// The script ended, with its completion value. + Done(JsValue), } /// A flag the host sets, from any thread, to stop the running script at its next instruction. /// It stays set until [`Interrupt::clear`]. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct Interrupt(Arc); impl Interrupt { + pub fn new() -> Self { + Self::default() + } + pub fn set(&self) { self.0.store(true, Ordering::Relaxed); } @@ -121,6 +143,7 @@ impl From for RunError { /// A global environment with the built-ins, in which scripts run one after another. pub struct Realm { interp: Interp, + unbroken_fuel: u64, } impl Default for Realm { @@ -138,7 +161,13 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits) } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL } + } + + /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default + /// [`MAX_UNBROKEN_FUEL`]). + pub fn set_unbroken_fuel(&mut self, fuel: u64) { + self.unbroken_fuel = fuel; } pub fn limits(&self) -> &Limits { @@ -173,6 +202,13 @@ impl Realm { /// value of the last expression statement that ran). A realm whose built-ins could not be /// installed runs nothing and gives an internal error. pub fn run(&mut self, source: &str) -> Result { + let code = self.compile(source)?; + let result = self.interp.run_global(code, false); + self.outcome(result) + } + + /// Compiles `source` and readies the realm for a run. + fn compile(&mut self, source: &str) -> Result, RunError> { let interp = &mut self.interp; if let Some(message) = &interp.install_error { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); @@ -181,13 +217,18 @@ impl Realm { interp.source_units = 0; interp.throw_site = None; interp.last_thrown = Value::Undefined; + interp.host_abort = None; let strict = interp.options.strict; - let code = match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) { - Ok(code) => code, - Err(CompileError::Syntax(e)) => return Err(e.into()), - Err(CompileError::Internal(m)) => return Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), - }; - let result = interp.run_global(code, false); + match interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)) { + Ok(code) => Ok(code), + Err(CompileError::Syntax(e)) => Err(e.into()), + Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), + } + } + + /// The end of a run: its completion value or error, with the interpreter's stacks emptied. + fn outcome(&mut self, result: Result) -> Result { + let interp = &mut self.interp; let (line, column) = interp.throw_site.take().map_or((0, 0), |(code, pc)| code.line_column(pc)); interp.stack.clear(); interp.frames.clear(); @@ -200,11 +241,84 @@ impl Realm { interp.last_thrown = v; Err(RunError { kind: RunErrorKind::Thrown, message, line, column }) } + Err(Abrupt::Limit(LimitKind::Host)) => { + let message = interp.host_abort.take().unwrap_or_else(|| format!("limit reached: {:?}", LimitKind::Host)); + Err(RunError { kind: RunErrorKind::Limit(LimitKind::Host), message, line, column }) + } Err(Abrupt::Limit(kind)) => Err(RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line, column }), Err(Abrupt::Internal(message)) => Err(RunError { kind: RunErrorKind::Internal, message, line, column }), } } + /// Begins a run that [`Realm::advance`] carries on: compiles `source` and enters it. + pub fn start(&mut self, source: &str) -> Result<(), RunError> { + let code = self.compile(source)?; + match self.interp.enter_global(code, false) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + + /// Runs the script begun with [`Realm::start`] for about `slice` units of fuel (at least one + /// instruction), and pauses where it can resume: between two instructions of the script's own + /// code. A getter, a built-in's callback or another nested run has native frames under it, so + /// it runs on to its end, for at most [`Realm::set_unbroken_fuel`] more. Once this has returned + /// [`Progress::Done`] or an error, the run is over. + pub fn advance(&mut self, slice: u64) -> Result { + let interp = &mut self.interp; + if interp.frames.is_empty() { + return Err(RunError { kind: RunErrorKind::Internal, message: "no run to advance".into(), line: 0, column: 0 }); + } + let total = interp.fuel; + let cap = slice.saturating_add(self.unbroken_fuel).min(total); + interp.fuel = cap; + let r = interp.execute_slice(cap - slice.min(cap)); + let used = cap - interp.fuel; + interp.fuel = total - used; + match r { + Ok(None) => Ok(Progress::Paused), + Ok(Some(v)) => self.outcome(Ok(v)).map(Progress::Done), + // The run's own fuel is not used up, so it was the allowance of this slice. + Err(Abrupt::Limit(LimitKind::Fuel)) if total > cap => self.outcome(Err(Abrupt::Limit(LimitKind::Unbroken))).map(Progress::Done), + Err(e) => self.outcome(Err(e)).map(Progress::Done), + } + } + + /// Is a run begun with [`Realm::start`] still going? + pub fn is_running(&self) -> bool { + !self.interp.frames.is_empty() + } + + /// The flag a host function sets to end the current slice at the next point it can pause. + pub fn pause_flag(&self) -> std::rc::Rc> { + self.interp.pause.clone() + } + + /// Has this realm stop when `interrupt` is set, in place of its own flag. + pub fn share_interrupt(&mut self, interrupt: &Interrupt) { + self.interp.interrupt = interrupt.0.clone(); + } + + /// Fuel the run has left. + pub fn fuel_left(&self) -> u64 { + self.interp.fuel + } + + /// Sets how much a value passed to or from a host function may carry. + pub fn set_data_limits(&mut self, limits: crate::DataLimits) { + self.interp.data_limits = limits; + } + + /// Adds the frozen object `name`, with `items`, to the global environment. Scripts cannot add + /// to it, change it or replace it. A heap too small for it ends with the error the realm's + /// next run would give. + pub fn define_host_object(&mut self, name: &str, items: Vec<(String, crate::HostItem)>) -> Result<(), RunError> { + match self.interp.install_host_object(name, items) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// Defines the global variable `name` holding the exception the last run did not catch /// (`undefined` if there was none). pub fn bind_last_thrown(&mut self, name: &str) { diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index c0139bccc..34094222b 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -28,7 +28,8 @@ const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; -/// The engine is running a long call and this request was not run (implementation-defined range). +/// The engine is running a long call or a script and this request was not run +/// (implementation-defined range). pub(crate) const SERVER_BUSY: i64 = -32003; /// How long a call runs before a request that would have to wait behind it is refused as busy @@ -435,7 +436,11 @@ impl Server { if let Some(arguments) = args.as_object() { crate::tools::check_tool_arguments(name, arguments).map_err(|e| (INVALID_PARAMS, e))?; } - Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + let result = call_tool(self.backend.as_mut(), name, &args); + match result.script_busy() { + Some(message) => Err((SERVER_BUSY, message)), + None => Ok(result.to_value()), + } } "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; diff --git a/crates/mcp/src/tests/concurrency.rs b/crates/mcp/src/tests/concurrency.rs index 7b9352e52..d291d1e9f 100644 --- a/crates/mcp/src/tests/concurrency.rs +++ b/crates/mcp/src/tests/concurrency.rs @@ -344,6 +344,23 @@ fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_sess thread.join().unwrap(); } +#[test] +fn a_change_while_a_script_runs_is_refused_as_busy() { + let mut backend = crate::Headless::with_document(); + backend.session.script_frames = true; + backend.session.execute("script.run", &json!({"source": "while (true) {}"})).unwrap(); + let mut s = Server::new(Box::new(backend)); + let call = |id: &str, params: Value| json!({"name": "command_run", "arguments": {"id": id, "params": params}}); + let reply = super::rpc(&mut s, 1, "tools/call", call("shape.rectangle", json!({"x": 0, "y": 0, "width": 1, "height": 1}))); + assert_eq!(reply["error"]["code"], -32003, "{reply}"); + assert!(reply["error"]["message"].as_str().unwrap().contains("a script is running"), "{reply}"); + let reply = super::rpc(&mut s, 2, "tools/call", json!({"name": "doc_inspect", "arguments": {}})); + assert_eq!(reply["result"]["isError"], false, "reading the document is not refused: {reply}"); + let reply = super::rpc(&mut s, 3, "tools/call", call("no.such", json!({}))); + assert_eq!(reply["result"]["isError"], true, "any other failure stays a tool error: {reply}"); + assert!(reply.get("error").is_none(), "{reply}"); +} + #[test] fn a_remote_call_stops_waiting_for_the_app_when_cancelled() { use std::io::BufRead; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 66080b6fa..93ef5749a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -36,4 +36,9 @@ impl ToolResult { pub fn to_value(&self) -> Value { json!({"content": self.content, "isError": self.is_error}) } + /// The error's text, when the engine refused the call because a script is running. + pub(crate) fn script_busy(&self) -> Option { + let text = self.content.first()?.get("text")?.as_str()?; + (self.is_error && text.ends_with(vectorcraft_engine::SCRIPT_BUSY)).then(|| text.to_string()) + } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 49775e241..81c601c1c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -53,6 +53,8 @@ impl VectorcraftApp { // The place cursor's clicks and drags place their files through the app // ([`place::place_from_cursor`]). session.place_cursor_requests = true; + // A script runs a slice per frame, so the window keeps drawing and Stop can be pressed. + session.script_frames = true; let views = session.documents().iter().map(View::of).collect(); let view_uids = session.documents().iter().map(|d| d.uid).collect(); let known = session.documents().iter().map(|d| d.uid).collect(); @@ -232,6 +234,19 @@ impl VectorcraftApp { /// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are /// called from the host's `eframe::App` impl. impl VectorcraftApp { + /// Applies the work that finished off the UI thread. It stays pending while a script runs, since + /// every change to the documents ends in a busy error until the script ends. + pub(crate) fn poll_finished_work(&mut self) { + if self.session.script_running() { + return; + } + background::poll(self); + opening::poll(self); + plugin_runs::poll(self); + trace_runs::poll(self); + placing::poll(self); + } + /// The language the UI is drawn in: the Preferences dialog's choice while it is open (so a /// change shows before OK), else the `interfaceLanguage` preference (`auto` = the system's). pub fn ui_language(&self) -> i18n::Lang { @@ -321,12 +336,12 @@ impl VectorcraftApp { // A Paste the host read before this iteration's formats were written must still wait for // them, even if the worker below finishes them during this same tick. ctx.data_mut(|d| d.insert_temp(egui::Id::unique("clipboard.pending-at-tick"), self.clipboard_publishing.is_pending())); - background::poll(self); screen_color::poll(self, ctx); - opening::poll(self); - plugin_runs::poll(self); - trace_runs::poll(self); - placing::poll(self); + self.poll_finished_work(); + crate::script_runs::poll(self); + if self.session.script_running() && !self.session.script_waiting() { + ctx.request_repaint(); + } #[cfg(not(target_arch = "wasm32"))] self.tick_link_updates(ctx, now); crate::addons::select::link_status::refresh(self, now); @@ -351,7 +366,12 @@ impl VectorcraftApp { crate::ime::close_viewport(self, ctx); self.ime_context.end_viewport(ctx.viewport_id()); unsaved::finish_pending(self); - if self.file_sheet_open() { + if self.session.script_running() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if self.ui.dialog.is_none() { + self.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, serde_json::json!({}))); + } + } else if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.status(picks::busy()); } else if unsaved::any_dirty(self) { diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9fbd02327..899508021 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -179,12 +179,18 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { } right = left - gap; } - let busy: Vec<&str> = app - .background - .jobs - .iter() - .filter(|j| !j.label.is_empty() && !j.cancelled()) - .map(|j| j.label.as_str()) + let script = app.session.script_running().then_some("Running a script"); + if script.is_some() { + let left = (right - 52.0).max(area.left()); + let stop = egui::Rect::from_min_max(egui::pos2(left, area.top() + 1.0), egui::pos2(right, area.bottom() - 1.0)); + if ui.put(stop, egui::Button::new(tl!("Stop")).small()).clicked() { + app.session.script_interrupt().set(); + } + right = left - gap; + } + let busy: Vec<&str> = script + .into_iter() + .chain(app.background.jobs.iter().filter(|j| !j.label.is_empty() && !j.cancelled()).map(|j| j.label.as_str())) .chain(app.opening.pending.iter().map(|p| p.label.as_str())) .chain(app.placing.pending.iter().map(|p| p.label.as_str())) .collect(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6af51d95a..dce986df6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -234,6 +234,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { fields.remove("floating_panels"); } json!({ + "scriptRunning": app.session.script_running(), + "scriptReport": app.session.script_report(), "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1f59647c1..b5100a2e8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -109,6 +109,7 @@ pub mod save_style_library; pub mod save_swatch_library; pub mod save_symbol_library; pub mod script_check; +pub mod script_run; pub mod shape_builder_options; pub(crate) mod shapes; pub mod shortcut_import; @@ -404,6 +405,9 @@ registry! { PluginImport: [plugin::IMPORT_KIND] => plugin::IMPORT_SPEC, PluginInstall: [plugin::INSTALL_KIND] => plugin::INSTALL_SPEC, ScriptCheck: [script_check::KIND] => script_check::SPEC, + ScriptRun: [script_run::KIND] => script_run::SPEC, + ScriptGrowth: [script_run::GROWTH_KIND] => script_run::GROWTH_SPEC, + ScriptClose: [script_run::CLOSE_KIND] => script_run::CLOSE_SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, @@ -570,6 +574,10 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == safe_mode::STOPPED { safe_mode::cancel_stopped(app); } + // The growth question's Cancel button is its Stop button. + if d.kind == script_run::GROWTH_KIND { + app.session.script_request_stop(); + } // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. diff --git a/crates/ui-egui/src/dialogs/script_run.rs b/crates/ui-egui/src/dialogs/script_run.rs new file mode 100644 index 000000000..2ef43cb8f --- /dev/null +++ b/crates/ui-egui/src/dialogs/script_run.rs @@ -0,0 +1,147 @@ +//! File › Scripts › Other Script…: choosing a JavaScript file and the warning before it runs +//! (`script.run` runs it), the question a run that keeps adding art asks, and the question quitting +//! asks while a script runs. +//! +//! `ui.runScript {path? | source?, name?}` shows the warning for that script; without either it +//! asks for a `.js` or `.jsx` file (the web opens the browser's file picker). With Preferences › +//! General › Show External Script Warning off, the chosen script runs at once. Warning fields: +//! `name`, `script` (`script.run`'s params) and `dontShow` (true: turn the warning off). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::scripts::{RUN_EXTS, TOO_LARGE, runs}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{FilePick, VectorcraftApp, widgets}; + +/// The dialog kind of the warning before a script runs. +pub const KIND: &str = "scriptRun"; +/// The dialog kind of the question a run asks when it has added a great deal. +pub const GROWTH_KIND: &str = "scriptGrowth"; +/// The dialog kind of the question quitting asks while a script runs. +pub const CLOSE_KIND: &str = "scriptClose"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Run Script").into(), + body, + confirm, + ok: Some("Run"), + min_width: 420.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; + +pub(super) const GROWTH_SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("A Script Is Running").into(), + body: |_, ui, _| { + ui.label(tl!("This script has added a great many objects. Continue, or stop it and undo what it did?")); + false + }, + confirm: growth_confirm, + ok: Some("Continue"), + cancel: Some("Stop"), + min_width: 420.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; + +pub(super) const CLOSE_SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("A Script Is Running").into(), + body: |_, ui, _| { + ui.label(tl!("Stop the script and undo what it did? Then quit again.")); + false + }, + confirm: close_confirm, + ok: Some("Stop"), + min_width: 420.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; + +/// A file the web's picker delivered: its name and bytes, or `None` for a file over +/// [`vectorcraft_engine::script::MAX_SOURCE_BYTES`], which is not read. +pub type ScriptInbox = std::sync::Arc>)>>>; + +/// Shows the warning for the next script the web's picker delivered, once no dialog is open. A +/// picker that was canceled delivers nothing. +pub(crate) fn drain(app: &mut VectorcraftApp) { + if app.ui.dialog.is_some() { + return; + } + let Some((name, bytes)) = app.services.script_inbox.as_ref().and_then(|q| q.lock().unwrap_or_else(|e| e.into_inner()).pop()) else { return }; + let result = match bytes.as_deref().map(std::str::from_utf8) { + None => Err(TOO_LARGE.to_string()), + Some(Ok(source)) => chosen(app, json!({"source": source, "name": name})), + Some(Err(_)) => Err("the script is not UTF-8 text".to_string()), + }; + if let Err(e) = result { + app.status(e); + } +} + +/// `ui.runScript`: the script at `path` or given as `source`; without either, pick a file. +pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result { + if p.get("path").is_some() || p.get("source").is_some() { + return chosen(app, p.clone()); + } + if let Some(pick) = app.services.script_async.as_mut() { + pick(); + return Ok(Value::Null); + } + let pick = FilePick { filters: vec![FilePick::filter("Scripts", RUN_EXTS)], ..Default::default() }; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; + chosen(app, json!({ "path": path })) +} + +/// A script was chosen: `params` are `script.run`'s. It runs after the warning, when that is on. +pub(crate) fn chosen(app: &mut VectorcraftApp, params: Value) -> Result { + let path = params.get("path").and_then(Value::as_str); + if path.is_some_and(|path| !runs(path)) { + return Err("Choose a .js or .jsx script".into()); + } + if !app.session.prefs.show_external_script_warning { + return app.run("script.run", params); + } + let name = params.get("name").and_then(Value::as_str).or(path).map_or_else(|| "script".to_string(), vectorcraft_engine::cmd::fileio::file_name); + app.ui.dialog = Some(Dialog::new(KIND, json!({"name": name, "script": params, "dontShow": false}))); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(d.fields.get("name").and_then(Value::as_str).unwrap_or_default()).font(theme::semibold(12.5)).color(t.text_strong)); + ui.add_space(4.0); + ui.label(tl!("A script can change the open documents and can write files through the commands it runs. Run only scripts you trust.")); + ui.add_space(8.0); + let dont_show = d.fields.get("dontShow").and_then(Value::as_bool).unwrap_or(false); + if widgets::check(ui, tl!("Do not show this warning again"), dont_show, true) { + d.fields.insert("dontShow".into(), json!(!dont_show)); + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.fields.get("dontShow").and_then(Value::as_bool).unwrap_or(false) { + app.run("prefs.set", json!({"key": "showExternalScriptWarning", "value": false}))?; + } + let script = d.fields.get("script").cloned().unwrap_or(Value::Null); + app.ui.dialog = None; + app.run("script.run", script) +} + +fn growth_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { + app.ui.dialog = None; + app.session.script_continue(); + Ok(Value::Null) +} + +fn close_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { + app.ui.dialog = None; + if app.session.script_abandon() { + let error = app.session.script_failure_text().unwrap_or_default(); + crate::script_runs::failed(app, error); + app.sync_views(); + } + Ok(Value::Null) +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b7006..3f6a08046 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3955,6 +3955,15 @@ axis Both И двете @msg Combine Slices: the slices have no bounds Обединяване на парчета: парчетата нямат граници @msg Convert to Artboards: selection has no area Преобразуване в работни области: селекцията няма площ @msg Convert to Shape: no rectangles or ellipses selected Преобразуване в фигура: няма избрани правоъгълници или елипси + Run Изпълни + Run Script Изпълни скрипт + Stop Script Спри скрипта + Show External Script Warning Показвай предупреждение за външни скриптове + A Script Is Running Изпълнява се скрипт + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скриптът може да променя отворените документи и да записва файлове чрез командите, които изпълнява. Изпълнявайте само скриптове, на които имате доверие. + Do not show this warning again Не показвай повече това предупреждение + Stop the script and undo what it did? Then quit again. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново. + This script has added a great many objects. Continue, or stop it and undo what it did? Този скрипт добави изключително много обекти. Да продължи ли, или да се спре и да се отменят промените му? @msg Copied the script report Копиран е отчетът за скрипта @msg Corners edits the corners of a path Ъглите редактират ъглите на пътя @msg Couldn't copy to the system clipboard: {e} Неуспешно копиране в системния клипборд: {e} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a655..6f502d59a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4320,6 +4320,15 @@ imageTrace Outline Obris @msg Combine Slices: the slices have no bounds Kombinuj isječke: isječci nemaju granice @msg Convert to Artboards: selection has no area Pretvori u radne ploče: odabir nema površinu @msg Convert to Shape: no rectangles or ellipses selected Pretvori u oblik: nisu odabrani pravougaonici ni elipse + Run Pokreni + Run Script Pokreni skriptu + Stop Script Zaustavi skriptu + Show External Script Warning Prikaži upozorenje za vanjske skripte + A Script Is Running Skripta se izvršava + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skripta može mijenjati otvorene dokumente i pisati datoteke putem naredbi koje pokreće. Pokrećite samo skripte kojima vjerujete. + Do not show this warning again Ne prikazuj više ovo upozorenje + Stop the script and undo what it did? Then quit again. Zaustaviti skriptu i poništiti ono što je uradila? Zatim ponovo izađite. + This script has added a great many objects. Continue, or stop it and undo what it did? Ova skripta je dodala veoma mnogo objekata. Nastaviti, ili je zaustaviti i poništiti ono što je uradila? @msg Copied the script report Izvještaj skripte kopiran @msg Corners edits the corners of a path Uglovi uređuje uglove putanje @msg Couldn't copy to the system clipboard: {e} Nije moguće kopirati u sistemski međuspremnik: {e} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 53ebd517f..74261f1ac 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5155,6 +5155,27 @@ digits Malayalam Malaiàlam @msg control must be fillStroke, coloring, drawMode or screenMode control ha de ser fillStroke, coloring, drawMode o screenMode @msg Convert to Artboards: selection has no area Converteix en taulers: la selecció no té àrea @msg Convert to Shape: no rectangles or ellipses selected Converteix en forma: no s’ha seleccionat cap rectangle ni el·lipse + Run Executa + Run Script Executa l'script + Stop Script Atura l'script + Show External Script Warning Mostra l'avís dels scripts externs + A Script Is Running S'està executant un script + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script pot canviar els documents oberts i escriure fitxers mitjançant les ordres que executa. Executeu només scripts de confiança. + Do not show this warning again No tornis a mostrar aquest avís + Stop the script and undo what it did? Then quit again. Voleu aturar l'script i desfer el que ha fet? Després, torneu a sortir. + This script has added a great many objects. Continue, or stop it and undo what it did? Aquest script ha afegit moltíssims objectes. Voleu continuar, o aturar-lo i desfer el que ha fet? +@msg Choose a .js or .jsx script Trieu un script .js o .jsx +@msg Ran {name} S'ha executat {name} +@msg The script was stopped and its changes were undone L'script s'ha aturat i s'han desfet els seus canvis +@msg a script is running: stop it or wait for it to finish s'està executant un script: atureu-lo o espereu que acabi +@msg this is a compiled script, which cannot be run; use its source text aquest és un script compilat, que no es pot executar; useu el seu text font +@msg the script is not UTF-8 text l'script no és text UTF-8 +@msg the script is too large to run l'script és massa gran per executar-lo +@msg a drag or an undo group is open; finish it before running a script hi ha un arrossegament o un grup de desfer obert; acabeu-lo abans d'executar un script +@msg the script left a document that cannot be kept l'script ha deixat un document que no es pot conservar +@msg the script's run is no longer available l'execució de l'script ja no està disponible +@msg a recovery copy could not be written, so the script was not run no s'ha pogut escriure una còpia de recuperació, per tant l'script no s'ha executat +@msg Running a script S'està executant un script @msg Copied the script report S’ha copiat l’informe de l’script @msg Corners edits the corners of a path Cantonades edita les cantonades d’un traçat @msg could not decode image: {0} no s’ha pogut descodificar la imatge: {0} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c9090be2..da6b54364 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4378,6 +4378,27 @@ imageTrace Outline Obrys @msg Combine Slices: the slices have no bounds Spojit řezy: řezy nemají hranice @msg Convert to Artboards: selection has no area Převést na pracovní plochy: výběr nemá žádnou plochu @msg Convert to Shape: no rectangles or ellipses selected Převést na tvar: nejsou vybrány žádné obdélníky ani elipsy + Run Spustit + Run Script Spustit skript + Stop Script Zastavit skript + Show External Script Warning Zobrazit upozornění u externích skriptů + A Script Is Running Běží skript + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skript může měnit otevřené dokumenty a prostřednictvím příkazů, které spouští, zapisovat soubory. Spouštějte jen skripty, kterým důvěřujete. + Do not show this warning again Toto upozornění již nezobrazovat + Stop the script and undo what it did? Then quit again. Zastavit skript a vrátit jeho změny? Potom aplikaci ukončete znovu. + This script has added a great many objects. Continue, or stop it and undo what it did? Tento skript přidal velmi mnoho objektů. Pokračovat, nebo jej zastavit a vrátit jeho změny? +@msg Choose a .js or .jsx script Vyberte skript .js nebo .jsx +@msg Ran {name} Spuštěno: {name} +@msg The script was stopped and its changes were undone Skript byl zastaven a jeho změny byly vráceny +@msg a script is running: stop it or wait for it to finish běží skript: zastavte jej nebo počkejte na jeho dokončení +@msg this is a compiled script, which cannot be run; use its source text toto je zkompilovaný skript, který nelze spustit; použijte jeho zdrojový text +@msg the script is not UTF-8 text skript není text v kódování UTF-8 +@msg the script is too large to run skript je příliš velký na spuštění +@msg a drag or an undo group is open; finish it before running a script je otevřené tažení nebo skupina kroků zpět; před spuštěním skriptu ji dokončete +@msg the script left a document that cannot be kept skript zanechal dokument, který nelze zachovat +@msg the script's run is no longer available běh skriptu již není k dispozici +@msg a recovery copy could not be written, so the script was not run záložní kopii se nepodařilo zapsat, proto skript nebyl spuštěn +@msg Running a script Běží skript @msg Copied the script report Zpráva o skriptu zkopírována @msg Corners edits the corners of a path Rohy upravuje rohy cesty @msg Couldn't copy to the system clipboard: {e} Nelze kopírovat do systémové schránky: {e} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718b..e7bd68fb0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4074,6 +4074,15 @@ imageTrace Outline Kontur @msg Combine Slices: the slices have no bounds Kombinér udsnit: udsnittene har ingen grænser @msg Convert to Artboards: selection has no area Konvertér til tegneflader: markeringen har intet areal @msg Convert to Shape: no rectangles or ellipses selected Konvertér til figur: ingen rektangler eller ellipser er markeret + Run Kør + Run Script Kør script + Stop Script Stop script + Show External Script Warning Vis advarsel om eksterne scripts + A Script Is Running Et script kører + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Et script kan ændre de åbne dokumenter og skrive filer gennem de kommandoer, det kører. Kør kun scripts, du stoler på. + Do not show this warning again Vis ikke denne advarsel igen + Stop the script and undo what it did? Then quit again. Stop scriptet og fortryd det, det har gjort? Afslut derefter igen. + This script has added a great many objects. Continue, or stop it and undo what it did? Dette script har tilføjet rigtig mange objekter. Fortsæt, eller stop det og fortryd det, det har gjort? @msg Copied the script report Kopierede scriptrapporten @msg Corners edits the corners of a path Hjørner redigerer hjørnerne på en kurve @msg Couldn't copy to the system clipboard: {e} Kunne ikke kopiere til systemets udklipsholder: {e} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6c6c48238..9bcec31d8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3983,6 +3983,27 @@ theme Appearance Erscheinungsbild @msg Combine Slices: the slices have no bounds Slices kombinieren: Die Slices haben keine Begrenzung @msg Convert to Artboards: selection has no area In Zeichenflächen umwandeln: Die Auswahl hat keine Fläche @msg Convert to Shape: no rectangles or ellipses selected In Form umwandeln: keine Rechtecke oder Ellipsen ausgewählt + Run Ausführen + Run Script Skript ausführen + Stop Script Skript stoppen + Show External Script Warning Warnung bei externen Skripten anzeigen + A Script Is Running Ein Skript wird ausgeführt + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ein Skript kann die geöffneten Dokumente ändern und über die ausgeführten Befehle Dateien schreiben. Führen Sie nur Skripte aus, denen Sie vertrauen. + Do not show this warning again Diese Warnung nicht mehr anzeigen + Stop the script and undo what it did? Then quit again. Skript stoppen und seine Änderungen rückgängig machen? Beenden Sie danach erneut. + This script has added a great many objects. Continue, or stop it and undo what it did? Dieses Skript hat sehr viele Objekte hinzugefügt. Fortfahren oder das Skript stoppen und seine Änderungen rückgängig machen? +@msg Choose a .js or .jsx script Wählen Sie ein .js- oder .jsx-Skript +@msg Ran {name} {name} ausgeführt +@msg The script was stopped and its changes were undone Das Skript wurde gestoppt und seine Änderungen wurden rückgängig gemacht +@msg a script is running: stop it or wait for it to finish ein Skript wird ausgeführt: stoppen Sie es oder warten Sie, bis es fertig ist +@msg this is a compiled script, which cannot be run; use its source text dies ist ein kompiliertes Skript, das nicht ausgeführt werden kann; verwenden Sie seinen Quelltext +@msg the script is not UTF-8 text das Skript ist kein UTF-8-Text +@msg the script is too large to run das Skript ist zu groß zum Ausführen +@msg a drag or an undo group is open; finish it before running a script ein Ziehvorgang oder eine Rückgängig-Gruppe ist offen; schließen Sie sie ab, bevor Sie ein Skript ausführen +@msg the script left a document that cannot be kept das Skript hat ein Dokument hinterlassen, das nicht behalten werden kann +@msg the script's run is no longer available der Lauf des Skripts ist nicht mehr verfügbar +@msg a recovery copy could not be written, so the script was not run eine Wiederherstellungskopie konnte nicht geschrieben werden, daher wurde das Skript nicht ausgeführt +@msg Running a script Skript wird ausgeführt @msg Copied the script report Skriptbericht kopiert @msg Corners edits the corners of a path „Ecken“ bearbeitet die Ecken eines Pfads @msg Couldn't copy to the system clipboard: {e} In die Systemzwischenablage konnte nicht kopiert werden: {e} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2d4f2a6e..c8a7ca759 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2233,6 +2233,27 @@ axis Both Και τα δύο @msg Combine Slices: the slices have no bounds Συνδυασμός τομών: οι τομές δεν έχουν όρια @msg Convert to Artboards: selection has no area Μετατροπή σε πίνακες σχεδίασης: η επιλογή δεν έχει επιφάνεια @msg Convert to Shape: no rectangles or ellipses selected Μετατροπή σε σχήμα: δεν έχουν επιλεγεί ορθογώνια ή ελλείψεις + Run Εκτέλεση + Run Script Εκτέλεση σεναρίου + Stop Script Διακοπή σεναρίου + Show External Script Warning Εμφάνιση προειδοποίησης για εξωτερικά σενάρια + A Script Is Running Εκτελείται ένα σενάριο + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ένα σενάριο μπορεί να αλλάξει τα ανοιχτά έγγραφα και να γράψει αρχεία μέσω των εντολών που εκτελεί. Εκτελείτε μόνο σενάρια που εμπιστεύεστε. + Do not show this warning again Να μην εμφανιστεί ξανά αυτή η προειδοποίηση + Stop the script and undo what it did? Then quit again. Διακοπή του σεναρίου και αναίρεση όσων έκανε; Έπειτα κάντε ξανά έξοδο. + This script has added a great many objects. Continue, or stop it and undo what it did? Αυτό το σενάριο πρόσθεσε πάρα πολλά αντικείμενα. Συνέχεια, ή διακοπή και αναίρεση όσων έκανε; +@msg Choose a .js or .jsx script Επιλέξτε ένα σενάριο .js ή .jsx +@msg Ran {name} Εκτελέστηκε: {name} +@msg The script was stopped and its changes were undone Το σενάριο διακόπηκε και οι αλλαγές του αναιρέθηκαν +@msg a script is running: stop it or wait for it to finish εκτελείται ένα σενάριο: διακόψτε το ή περιμένετε να τελειώσει +@msg this is a compiled script, which cannot be run; use its source text αυτό είναι μεταγλωττισμένο σενάριο που δεν μπορεί να εκτελεστεί· χρησιμοποιήστε το πηγαίο κείμενό του +@msg the script is not UTF-8 text το σενάριο δεν είναι κείμενο UTF-8 +@msg the script is too large to run το σενάριο είναι πολύ μεγάλο για εκτέλεση +@msg a drag or an undo group is open; finish it before running a script είναι ανοιχτή μια μεταφορά ή ομάδα αναίρεσης· ολοκληρώστε την πριν εκτελέσετε σενάριο +@msg the script left a document that cannot be kept το σενάριο άφησε ένα έγγραφο που δεν μπορεί να διατηρηθεί +@msg the script's run is no longer available η εκτέλεση του σεναρίου δεν είναι πλέον διαθέσιμη +@msg a recovery copy could not be written, so the script was not run δεν ήταν δυνατή η εγγραφή αντιγράφου ανάκτησης, οπότε το σενάριο δεν εκτελέστηκε +@msg Running a script Εκτελείται σενάριο @msg Copied the script report Η αναφορά του σεναρίου αντιγράφηκε @msg Corners edits the corners of a path Η εντολή Γωνίες επεξεργάζεται τις γωνίες μιας διαδρομής @msg Couldn't copy to the system clipboard: {e} Δεν ήταν δυνατή η αντιγραφή στο πρόχειρο συστήματος: {e} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9d5059411..692632fec 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4068,6 +4068,27 @@ axis Both Ambos @msg Combine Slices: the slices have no bounds Combinar sectores: los sectores no tienen límites @msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área @msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados + Run Ejecutar + Run Script Ejecutar script + Stop Script Detener script + Show External Script Warning Mostrar advertencia de scripts externos + A Script Is Running Se está ejecutando un script + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script puede cambiar los documentos abiertos y escribir archivos mediante los comandos que ejecuta. Ejecute solo scripts de confianza. + Do not show this warning again No volver a mostrar esta advertencia + Stop the script and undo what it did? Then quit again. ¿Detener el script y deshacer lo que hizo? Después, vuelva a salir. + This script has added a great many objects. Continue, or stop it and undo what it did? Este script ha añadido muchísimos objetos. ¿Continuar, o detenerlo y deshacer lo que hizo? +@msg Choose a .js or .jsx script Elija un script .js o .jsx +@msg Ran {name} Se ejecutó {name} +@msg The script was stopped and its changes were undone El script se detuvo y sus cambios se deshicieron +@msg a script is running: stop it or wait for it to finish se está ejecutando un script: deténgalo o espere a que termine +@msg this is a compiled script, which cannot be run; use its source text este es un script compilado, que no se puede ejecutar; use su texto fuente +@msg the script is not UTF-8 text el script no es texto UTF-8 +@msg the script is too large to run el script es demasiado grande para ejecutarlo +@msg a drag or an undo group is open; finish it before running a script hay un arrastre o un grupo de deshacer abierto; termínelo antes de ejecutar un script +@msg the script left a document that cannot be kept el script dejó un documento que no se puede conservar +@msg the script's run is no longer available la ejecución del script ya no está disponible +@msg a recovery copy could not be written, so the script was not run no se pudo escribir una copia de recuperación, así que el script no se ejecutó +@msg Running a script Ejecutando un script @msg Copied the script report Se copió el informe del script @msg Corners edits the corners of a path Esquinas edita las esquinas de un trazado @msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dbdf5d111..c242606a6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4292,6 +4292,27 @@ imageTrace Outline Ääriviiva @msg Combine Slices: the slices have no bounds Yhdistä viipaleet: viipaleilla ei ole rajoja @msg Convert to Artboards: selection has no area Muunna piirtoalustoiksi: valinnalla ei ole pinta-alaa @msg Convert to Shape: no rectangles or ellipses selected Muunna muodoksi: suorakulmioita tai ellipsejä ei ole valittu + Run Suorita + Run Script Suorita komentosarja + Stop Script Pysäytä komentosarja + Show External Script Warning Näytä ulkoisten komentosarjojen varoitus + A Script Is Running Komentosarja on käynnissä + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Komentosarja voi muuttaa avoimia asiakirjoja ja kirjoittaa tiedostoja suorittamillaan komennoilla. Suorita vain komentosarjoja, joihin luotat. + Do not show this warning again Älä näytä tätä varoitusta uudelleen + Stop the script and undo what it did? Then quit again. Pysäytetäänkö komentosarja ja kumotaan sen muutokset? Lopeta sitten uudelleen. + This script has added a great many objects. Continue, or stop it and undo what it did? Tämä komentosarja on lisännyt hyvin paljon objekteja. Jatketaanko, vai pysäytetäänkö se ja kumotaan sen muutokset? +@msg Choose a .js or .jsx script Valitse .js- tai .jsx-komentosarja +@msg Ran {name} Suoritettu: {name} +@msg The script was stopped and its changes were undone Komentosarja pysäytettiin ja sen muutokset kumottiin +@msg a script is running: stop it or wait for it to finish komentosarja on käynnissä: pysäytä se tai odota sen valmistumista +@msg this is a compiled script, which cannot be run; use its source text tämä on käännetty komentosarja, jota ei voi suorittaa; käytä sen lähdetekstiä +@msg the script is not UTF-8 text komentosarja ei ole UTF-8-tekstiä +@msg the script is too large to run komentosarja on liian suuri suoritettavaksi +@msg a drag or an undo group is open; finish it before running a script vetäminen tai kumousryhmä on kesken; viimeistele se ennen komentosarjan suorittamista +@msg the script left a document that cannot be kept komentosarja jätti asiakirjan, jota ei voi säilyttää +@msg the script's run is no longer available komentosarjan suoritus ei ole enää käytettävissä +@msg a recovery copy could not be written, so the script was not run palautuskopiota ei voitu kirjoittaa, joten komentosarjaa ei suoritettu +@msg Running a script Suoritetaan komentosarjaa @msg Copied the script report Komentosarjan raportti kopioitiin @msg Corners edits the corners of a path Kulmat-toiminto muokkaa polun kulmia @msg Couldn't copy to the system clipboard: {e} Järjestelmän leikepöydälle ei voitu kopioida: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7c6d1fb0e..7e8b075db 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4057,6 +4057,27 @@ axis Both Les deux @msg Combine Slices: the slices have no bounds Combiner les tranches : les tranches n’ont pas de limites @msg Convert to Artboards: selection has no area Convertir en plans de travail : la sélection n’a pas de surface @msg Convert to Shape: no rectangles or ellipses selected Convertir en forme : aucun rectangle ni aucune ellipse sélectionné + Run Exécuter + Run Script Exécuter le script + Stop Script Arrêter le script + Show External Script Warning Afficher l’avertissement des scripts externes + A Script Is Running Un script est en cours d’exécution + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script peut modifier les documents ouverts et écrire des fichiers au moyen des commandes qu’il exécute. N’exécutez que des scripts de confiance. + Do not show this warning again Ne plus afficher cet avertissement + Stop the script and undo what it did? Then quit again. Arrêter le script et annuler ce qu’il a fait ? Quittez ensuite à nouveau. + This script has added a great many objects. Continue, or stop it and undo what it did? Ce script a ajouté un très grand nombre d’objets. Continuer, ou l’arrêter et annuler ce qu’il a fait ? +@msg Choose a .js or .jsx script Choisissez un script .js ou .jsx +@msg Ran {name} {name} exécuté +@msg The script was stopped and its changes were undone Le script a été arrêté et ses modifications ont été annulées +@msg a script is running: stop it or wait for it to finish un script est en cours d’exécution : arrêtez-le ou attendez qu’il se termine +@msg this is a compiled script, which cannot be run; use its source text ce script est compilé et ne peut pas être exécuté ; utilisez son texte source +@msg the script is not UTF-8 text le script n’est pas du texte UTF-8 +@msg the script is too large to run le script est trop volumineux pour être exécuté +@msg a drag or an undo group is open; finish it before running a script un glissement ou un groupe d’annulation est ouvert ; terminez-le avant d’exécuter un script +@msg the script left a document that cannot be kept le script a laissé un document qui ne peut pas être conservé +@msg the script's run is no longer available l’exécution du script n’est plus disponible +@msg a recovery copy could not be written, so the script was not run une copie de récupération n’a pas pu être écrite, le script n’a donc pas été exécuté +@msg Running a script Exécution d’un script @msg Copied the script report Rapport du script copié @msg Corners edits the corners of a path Angles modifie les angles d’un tracé @msg Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1dac03dec..9c5f73e18 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6129,6 +6129,27 @@ antiAlias Sharp शार्प @msg control must be fillStroke, coloring, drawMode or screenMode control को fillStroke, coloring, drawMode या screenMode होना चाहिए @msg Convert to Artboards: selection has no area आर्टबोर्ड में कनवर्ट करें: सिलेक्शन में कोई क्षेत्र नहीं है @msg Convert to Shape: no rectangles or ellipses selected आकार में कनवर्ट करें: कोई आयत या अंडाकार सिलेक्शन नहीं + Run चलाएँ + Run Script स्क्रिप्ट चलाएँ + Stop Script स्क्रिप्ट रोकें + Show External Script Warning बाहरी स्क्रिप्ट की चेतावनी दिखाएँ + A Script Is Running एक स्क्रिप्ट चल रही है + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. स्क्रिप्ट खुले दस्तावेज़ बदल सकती है और अपने चलाए कमांड से फ़ाइलें लिख सकती है। केवल भरोसेमंद स्क्रिप्ट चलाएँ। + Do not show this warning again यह चेतावनी फिर न दिखाएँ + Stop the script and undo what it did? Then quit again. स्क्रिप्ट रोककर उसके बदलाव पूर्ववत करें? फिर दोबारा बाहर निकलें। + This script has added a great many objects. Continue, or stop it and undo what it did? इस स्क्रिप्ट ने बहुत सारे ऑब्जेक्ट जोड़े हैं। जारी रखें, या इसे रोककर इसके बदलाव पूर्ववत करें? +@msg Choose a .js or .jsx script .js या .jsx स्क्रिप्ट चुनें +@msg Ran {name} {name} चलाई गई +@msg The script was stopped and its changes were undone स्क्रिप्ट रोकी गई और उसके बदलाव पूर्ववत किए गए +@msg a script is running: stop it or wait for it to finish एक स्क्रिप्ट चल रही है: उसे रोकें या पूरा होने की प्रतीक्षा करें +@msg this is a compiled script, which cannot be run; use its source text यह कंपाइल की हुई स्क्रिप्ट है, जिसे चलाया नहीं जा सकता; इसका स्रोत पाठ इस्तेमाल करें +@msg the script is not UTF-8 text स्क्रिप्ट UTF-8 पाठ नहीं है +@msg the script is too large to run स्क्रिप्ट चलाने के लिए बहुत बड़ी है +@msg a drag or an undo group is open; finish it before running a script कोई ड्रैग या पूर्ववत समूह खुला है; स्क्रिप्ट चलाने से पहले उसे पूरा करें +@msg the script left a document that cannot be kept स्क्रिप्ट ने ऐसा दस्तावेज़ छोड़ा है जिसे रखा नहीं जा सकता +@msg the script's run is no longer available स्क्रिप्ट का रन अब उपलब्ध नहीं है +@msg a recovery copy could not be written, so the script was not run रिकवरी प्रति नहीं लिखी जा सकी, इसलिए स्क्रिप्ट नहीं चलाई गई +@msg Running a script स्क्रिप्ट चल रही है @msg Copied the script report Copied स्क्रिप्ट रिपोर्ट @msg copy needs path: the file to copy into the document's folder copy: प्रतिलिपि की जरूरत है पाथ: फ़ाइल को डॉक्यूमेंट के फ़ोल्डर में कॉपी करने के लिए @msg copy takes images only: relink placed documents where their files are copy: प्रतिलिपि केवल इमेज को लेती है: डॉक्यूमेंट्स को फिर से लिंक करें जहां उनकी फ़ाइलें हैं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 87842df37..16451a4ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4863,6 +4863,27 @@ imageTrace Outline Körvonal @msg compressed (RLE) bitmaps can't be read yet a tömörített (RLE) bitképek még nem olvashatók @msg Convert to Artboards: selection has no area Átalakítás rajztáblákká: a kijelölésnek nincs területe @msg Convert to Shape: no rectangles or ellipses selected Átalakítás alakzattá: nincs kijelölt téglalap vagy ellipszis + Run Futtatás + Run Script Parancsfájl futtatása + Stop Script Parancsfájl leállítása + Show External Script Warning Figyelmeztetés külső parancsfájlok előtt + A Script Is Running Egy parancsfájl fut + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. A parancsfájl módosíthatja a megnyitott dokumentumokat, és a futtatott parancsokkal fájlokat írhat. Csak megbízható parancsfájlokat futtasson. + Do not show this warning again Ne jelenjen meg többé ez a figyelmeztetés + Stop the script and undo what it did? Then quit again. Leállítja a parancsfájlt, és visszavonja a módosításait? Ezután lépjen ki újra. + This script has added a great many objects. Continue, or stop it and undo what it did? Ez a parancsfájl nagyon sok objektumot adott hozzá. Folytatja, vagy leállítja és visszavonja a módosításait? +@msg Choose a .js or .jsx script Válasszon .js vagy .jsx parancsfájlt +@msg Ran {name} Lefutott: {name} +@msg The script was stopped and its changes were undone A parancsfájl leállt, a módosításai visszavonva +@msg a script is running: stop it or wait for it to finish egy parancsfájl fut: állítsa le, vagy várja meg, hogy befejeződjön +@msg this is a compiled script, which cannot be run; use its source text ez lefordított parancsfájl, amely nem futtatható; használja a forrásszövegét +@msg the script is not UTF-8 text a parancsfájl nem UTF-8 szöveg +@msg the script is too large to run a parancsfájl túl nagy a futtatáshoz +@msg a drag or an undo group is open; finish it before running a script húzás vagy visszavonási csoport van folyamatban; fejezze be a parancsfájl futtatása előtt +@msg the script left a document that cannot be kept a parancsfájl olyan dokumentumot hagyott hátra, amely nem tartható meg +@msg the script's run is no longer available a parancsfájl futása már nem érhető el +@msg a recovery copy could not be written, so the script was not run a helyreállítási másolatot nem sikerült kiírni, ezért a parancsfájl nem futott le +@msg Running a script Parancsfájl futtatása @msg Copied the script report Szkriptjelentés másolva @msg Corners edits the corners of a path a Sarkok parancs egy útvonal sarkait szerkeszti @msg could not decode image: {0} a kép nem dekódolható: {0} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f..a09b4ed61 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4074,6 +4074,15 @@ imageTrace Outline Kontur @msg Combine Slices: the slices have no bounds Gabungkan irisan: irisan tidak memiliki batas @msg Convert to Artboards: selection has no area Konversi ke artboard: seleksi tidak memiliki area @msg Convert to Shape: no rectangles or ellipses selected Konversi ke bentuk: tidak ada persegi panjang atau elips yang dipilih + Run Jalankan + Run Script Jalankan Skrip + Stop Script Hentikan Skrip + Show External Script Warning Tampilkan Peringatan Skrip Eksternal + A Script Is Running Skrip Sedang Berjalan + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrip dapat mengubah dokumen yang terbuka dan menulis file melalui perintah yang dijalankannya. Jalankan hanya skrip yang Anda percayai. + Do not show this warning again Jangan tampilkan peringatan ini lagi + Stop the script and undo what it did? Then quit again. Hentikan skrip dan batalkan yang telah dilakukannya? Lalu keluar lagi. + This script has added a great many objects. Continue, or stop it and undo what it did? Skrip ini telah menambahkan sangat banyak objek. Lanjutkan, atau hentikan dan batalkan yang telah dilakukannya? @msg Copied the script report Berhasil menyalin laporan skrip @msg Corners edits the corners of a path Corners menyunting sudut jalur @msg Couldn't copy to the system clipboard: {e} Tidak dapat menyalin ke papan klip sistem: {e} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f10e1f272..762f4093a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4110,6 +4110,27 @@ axis Both Entrambi @msg Combine Slices: the slices have no bounds Combina sezioni: le sezioni non hanno limiti @msg Convert to Artboards: selection has no area Converti in tavole da disegno: la selezione non ha area @msg Convert to Shape: no rectangles or ellipses selected Converti in forma: nessun rettangolo o ellisse selezionato + Run Esegui + Run Script Esegui script + Stop Script Interrompi script + Show External Script Warning Mostra avviso per gli script esterni + A Script Is Running È in esecuzione uno script + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Uno script può modificare i documenti aperti e scrivere file tramite i comandi che esegue. Esegui solo script di cui ti fidi. + Do not show this warning again Non mostrare più questo avviso + Stop the script and undo what it did? Then quit again. Interrompere lo script e annullare ciò che ha fatto? Poi esci di nuovo. + This script has added a great many objects. Continue, or stop it and undo what it did? Questo script ha aggiunto moltissimi oggetti. Continuare, oppure interromperlo e annullare ciò che ha fatto? +@msg Choose a .js or .jsx script Scegli uno script .js o .jsx +@msg Ran {name} {name} eseguito +@msg The script was stopped and its changes were undone Lo script è stato interrotto e le sue modifiche sono state annullate +@msg a script is running: stop it or wait for it to finish è in esecuzione uno script: interrompilo o attendi che finisca +@msg this is a compiled script, which cannot be run; use its source text questo è uno script compilato, che non può essere eseguito; usa il suo testo sorgente +@msg the script is not UTF-8 text lo script non è testo UTF-8 +@msg the script is too large to run lo script è troppo grande per essere eseguito +@msg a drag or an undo group is open; finish it before running a script è aperto un trascinamento o un gruppo di annullamento; completalo prima di eseguire uno script +@msg the script left a document that cannot be kept lo script ha lasciato un documento che non può essere conservato +@msg the script's run is no longer available l'esecuzione dello script non è più disponibile +@msg a recovery copy could not be written, so the script was not run non è stato possibile scrivere una copia di recupero, quindi lo script non è stato eseguito +@msg Running a script Esecuzione di uno script @msg Copied the script report Rapporto dello script copiato @msg Corners edits the corners of a path Angoli modifica gli angoli di un tracciato @msg Couldn't copy to the system clipboard: {e} Impossibile copiare negli appunti di sistema: {e} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2c2b37a0..a34df35ab 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4819,6 +4819,27 @@ swatch Bright Violet ビビッドバイオレット @msg the library item is damaged ライブラリの項目が破損しています @msg {_1} font families available {_1} 個のフォントファミリーを使用できます @msg {_1} has no {_2} style {_1} には {_2} スタイルがありません + Run 実行 + Run Script スクリプトを実行 + Stop Script スクリプトを停止 + Show External Script Warning 外部スクリプトの警告を表示 + A Script Is Running スクリプトの実行中 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. スクリプトは開いているドキュメントを変更でき、実行するコマンドを通じてファイルを書き込むこともできます。信頼できるスクリプトだけを実行してください。 + Do not show this warning again 今後この警告を表示しない + Stop the script and undo what it did? Then quit again. スクリプトを停止して変更を取り消しますか?その後、もう一度終了してください。 + This script has added a great many objects. Continue, or stop it and undo what it did? このスクリプトは非常に多くのオブジェクトを追加しました。続行しますか、それとも停止して変更を取り消しますか? +@msg Choose a .js or .jsx script .js または .jsx スクリプトを選択してください +@msg Ran {name} {name} を実行しました +@msg The script was stopped and its changes were undone スクリプトを停止し、変更を取り消しました +@msg a script is running: stop it or wait for it to finish スクリプトの実行中です: 停止するか、終了するまでお待ちください +@msg this is a compiled script, which cannot be run; use its source text これはコンパイル済みのスクリプトなので実行できません。ソーステキストを使用してください +@msg the script is not UTF-8 text スクリプトは UTF-8 テキストではありません +@msg the script is too large to run スクリプトが大きすぎて実行できません +@msg a drag or an undo group is open; finish it before running a script ドラッグまたは取り消しグループが開いています。スクリプトを実行する前に完了してください +@msg the script left a document that cannot be kept スクリプトが保持できないドキュメントを残しました +@msg the script's run is no longer available スクリプトの実行は利用できなくなりました +@msg a recovery copy could not be written, so the script was not run 復元用のコピーを書き込めなかったため、スクリプトは実行されませんでした +@msg Running a script スクリプトを実行中 @msg Copied the script report スクリプトのレポートをコピーしました @msg Exported {path} {path} を書き出しました @msg no CSS to copy: select objects with CSS of their own コピーする CSS がありません: 独自の CSS を持つオブジェクトを選択してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a625c7bf5..7e308d05e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5128,6 +5128,27 @@ mark-style Japanese იაპონური Auto Grouping ავტომატური დაჯგუფება Ignore Color ფერის იგნორირება imageTrace Outline კონტური + Run გაშვება + Run Script სკრიპტის გაშვება + Stop Script სკრიპტის შეჩერება + Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება + A Script Is Running სკრიპტი მუშაობს + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. სკრიპტს შეუძლია ღია დოკუმენტების შეცვლა და მის მიერ გაშვებული ბრძანებებით ფაილების ჩაწერა. გაუშვით მხოლოდ სანდო სკრიპტები. + Do not show this warning again აღარ აჩვენო ეს გაფრთხილება + Stop the script and undo what it did? Then quit again. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით. + This script has added a great many objects. Continue, or stop it and undo what it did? ამ სკრიპტმა ძალიან ბევრი ობიექტი დაამატა. გაგრძელდეს, თუ შეჩერდეს და გაუქმდეს მისი ცვლილებები? +@msg Choose a .js or .jsx script აირჩიეთ .js ან .jsx სკრიპტი +@msg Ran {name} გაშვებულია: {name} +@msg The script was stopped and its changes were undone სკრიპტი შეჩერდა და მისი ცვლილებები გაუქმდა +@msg a script is running: stop it or wait for it to finish სკრიპტი მუშაობს: შეაჩერეთ ან დაელოდეთ დასრულებას +@msg this is a compiled script, which cannot be run; use its source text ეს კომპილირებული სკრიპტია და მისი გაშვება შეუძლებელია; გამოიყენეთ მისი საწყისი ტექსტი +@msg the script is not UTF-8 text სკრიპტი არ არის UTF-8 ტექსტი +@msg the script is too large to run სკრიპტი გასაშვებად ძალიან დიდია +@msg a drag or an undo group is open; finish it before running a script ღიაა გადათრევა ან გაუქმების ჯგუფი; დაასრულეთ სკრიპტის გაშვებამდე +@msg the script left a document that cannot be kept სკრიპტმა დატოვა დოკუმენტი, რომლის შენახვა შეუძლებელია +@msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი +@msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა +@msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) ტრაექტორიის წანაცვლება: წანაცვლების შემდეგ მონიშნული ტრაექტორიებიდან არაფერი რჩება (შიგნით წანაცვლება ფიგურებზე დიდია) @msg {path}: not a file {path}: ფაილი არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e281e8316..3fdbe952a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4746,6 +4746,27 @@ theme Appearance 외관 @msg the library item is damaged 라이브러리 항목이 손상되었습니다 @msg {_1} font families available 사용 가능한 글꼴 패밀리 {_1}개 @msg {_1} has no {_2} style {_1}에는 {_2} 스타일이 없습니다 + Run 실행 + Run Script 스크립트 실행 + Stop Script 스크립트 중지 + Show External Script Warning 외부 스크립트 경고 표시 + A Script Is Running 스크립트 실행 중 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 스크립트는 열려 있는 문서를 변경하고 실행하는 명령을 통해 파일을 쓸 수 있습니다. 신뢰할 수 있는 스크립트만 실행하세요. + Do not show this warning again 이 경고를 다시 표시하지 않음 + Stop the script and undo what it did? Then quit again. 스크립트를 중지하고 변경 내용을 취소할까요? 그런 다음 다시 종료하세요. + This script has added a great many objects. Continue, or stop it and undo what it did? 이 스크립트가 매우 많은 개체를 추가했습니다. 계속할까요, 아니면 중지하고 변경 내용을 취소할까요? +@msg Choose a .js or .jsx script .js 또는 .jsx 스크립트를 선택하세요 +@msg Ran {name} {name} 실행됨 +@msg The script was stopped and its changes were undone 스크립트를 중지하고 변경 내용을 취소했습니다 +@msg a script is running: stop it or wait for it to finish 스크립트 실행 중: 중지하거나 끝날 때까지 기다리세요 +@msg this is a compiled script, which cannot be run; use its source text 컴파일된 스크립트이므로 실행할 수 없습니다. 소스 텍스트를 사용하세요 +@msg the script is not UTF-8 text 스크립트가 UTF-8 텍스트가 아닙니다 +@msg the script is too large to run 스크립트가 너무 커서 실행할 수 없습니다 +@msg a drag or an undo group is open; finish it before running a script 드래그 또는 실행 취소 그룹이 열려 있습니다. 스크립트를 실행하기 전에 완료하세요 +@msg the script left a document that cannot be kept 스크립트가 유지할 수 없는 문서를 남겼습니다 +@msg the script's run is no longer available 스크립트 실행을 더 이상 사용할 수 없습니다 +@msg a recovery copy could not be written, so the script was not run 복구 사본을 쓸 수 없어 스크립트를 실행하지 않았습니다 +@msg Running a script 스크립트 실행 중 @msg Copied the script report 스크립트 보고서를 복사했습니다 @msg Exported {path} {path}을(를) 내보냈습니다 @msg no CSS to copy: select objects with CSS of their own 복사할 CSS가 없습니다: 자체 CSS가 있는 개체를 선택하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3..7ff35cdfd 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4088,6 +4088,15 @@ imageTrace Outline Contour @msg Combine Slices: the slices have no bounds Slices combineren: de slices hebben geen grenzen @msg Convert to Artboards: selection has no area Converteren naar tekengebieden: de selectie heeft geen oppervlakte @msg Convert to Shape: no rectangles or ellipses selected Converteren naar vorm: geen rechthoeken of ellipsen geselecteerd + Run Uitvoeren + Run Script Script uitvoeren + Stop Script Script stoppen + Show External Script Warning Waarschuwing voor externe scripts tonen + A Script Is Running Er wordt een script uitgevoerd + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Een script kan de geopende documenten wijzigen en via de uitgevoerde opdrachten bestanden schrijven. Voer alleen scripts uit die u vertrouwt. + Do not show this warning again Deze waarschuwing niet meer tonen + Stop the script and undo what it did? Then quit again. Het script stoppen en de wijzigingen ervan ongedaan maken? Sluit daarna opnieuw af. + This script has added a great many objects. Continue, or stop it and undo what it did? Dit script heeft heel veel objecten toegevoegd. Doorgaan, of het stoppen en de wijzigingen ervan ongedaan maken? @msg Copied the script report Scriptrapport gekopieerd @msg Corners edits the corners of a path Hoeken bewerkt de hoeken van een pad @msg Couldn't copy to the system clipboard: {e} Kon niet naar het systeemklembord kopiëren: {e} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d1be1679..4fcdf3f7b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4201,6 +4201,27 @@ imageTrace Outline Kontur @msg Combine Slices: the slices have no bounds Połącz plasterki: plasterki nie mają granic @msg Convert to Artboards: selection has no area Konwertuj na obszary kompozycji: zaznaczenie nie ma powierzchni @msg Convert to Shape: no rectangles or ellipses selected Konwertuj na kształt: nie zaznaczono prostokątów ani elips + Run Uruchom + Run Script Uruchom skrypt + Stop Script Zatrzymaj skrypt + Show External Script Warning Pokazuj ostrzeżenie o skryptach zewnętrznych + A Script Is Running Skrypt jest uruchomiony + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrypt może zmieniać otwarte dokumenty i zapisywać pliki za pomocą wykonywanych poleceń. Uruchamiaj tylko skrypty, którym ufasz. + Do not show this warning again Nie pokazuj więcej tego ostrzeżenia + Stop the script and undo what it did? Then quit again. Zatrzymać skrypt i cofnąć jego zmiany? Następnie zakończ program ponownie. + This script has added a great many objects. Continue, or stop it and undo what it did? Ten skrypt dodał bardzo wiele obiektów. Kontynuować, czy zatrzymać go i cofnąć jego zmiany? +@msg Choose a .js or .jsx script Wybierz skrypt .js lub .jsx +@msg Ran {name} Wykonano: {name} +@msg The script was stopped and its changes were undone Skrypt został zatrzymany, a jego zmiany cofnięte +@msg a script is running: stop it or wait for it to finish skrypt jest uruchomiony: zatrzymaj go lub poczekaj na jego zakończenie +@msg this is a compiled script, which cannot be run; use its source text to jest skrypt skompilowany, którego nie można uruchomić; użyj jego tekstu źródłowego +@msg the script is not UTF-8 text skrypt nie jest tekstem UTF-8 +@msg the script is too large to run skrypt jest zbyt duży, aby go uruchomić +@msg a drag or an undo group is open; finish it before running a script otwarte jest przeciąganie lub grupa cofania; zakończ je przed uruchomieniem skryptu +@msg the script left a document that cannot be kept skrypt pozostawił dokument, którego nie można zachować +@msg the script's run is no longer available uruchomienie skryptu nie jest już dostępne +@msg a recovery copy could not be written, so the script was not run nie udało się zapisać kopii odzyskiwania, więc skrypt nie został uruchomiony +@msg Running a script Uruchamianie skryptu @msg Copied the script report Skopiowano raport skryptu @msg Corners edits the corners of a path Narożniki edytuje narożniki ścieżki @msg Couldn't copy to the system clipboard: {e} Nie udało się skopiować do schowka systemowego: {e} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ebe44a68c..8f7351a7e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5019,6 +5019,27 @@ axis Both Ambos [Normal Paragraph Style] [Estilo de Parágrafo Normal] Joins Junções Axis Eixo + Run Executar + Run Script Executar script + Stop Script Parar script + Show External Script Warning Mostrar aviso de scripts externos + A Script Is Running Um script está em execução + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Um script pode alterar os documentos abertos e gravar arquivos pelos comandos que executa. Execute apenas scripts em que você confia. + Do not show this warning again Não mostrar este aviso novamente + Stop the script and undo what it did? Then quit again. Parar o script e desfazer o que ele fez? Depois, saia novamente. + This script has added a great many objects. Continue, or stop it and undo what it did? Este script adicionou muitíssimos objetos. Continuar, ou pará-lo e desfazer o que ele fez? +@msg Choose a .js or .jsx script Escolha um script .js ou .jsx +@msg Ran {name} {name} executado +@msg The script was stopped and its changes were undone O script foi parado e suas alterações foram desfeitas +@msg a script is running: stop it or wait for it to finish um script está em execução: pare-o ou aguarde até que termine +@msg this is a compiled script, which cannot be run; use its source text este é um script compilado, que não pode ser executado; use seu texto-fonte +@msg the script is not UTF-8 text o script não é texto UTF-8 +@msg the script is too large to run o script é grande demais para ser executado +@msg a drag or an undo group is open; finish it before running a script há um arraste ou um grupo de desfazer aberto; conclua-o antes de executar um script +@msg the script left a document that cannot be kept o script deixou um documento que não pode ser mantido +@msg the script's run is no longer available a execução do script não está mais disponível +@msg a recovery copy could not be written, so the script was not run não foi possível gravar uma cópia de recuperação, portanto o script não foi executado +@msg Running a script Executando um script @msg Copied the script report Relatório do script copiado @msg This shortcut file is in a form VectorCraft can't read. Este arquivo de atalhos está em um formato que o VectorCraft não consegue ler. @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve ser "vertical", "horizontal" ou um ângulo em graus, não `{v}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dc6402e83..b5241fa11 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4104,6 +4104,27 @@ axis Both Оба @msg Combine Slices: the slices have no bounds Объединение фрагментов: у фрагментов нет границ @msg Convert to Artboards: selection has no area Преобразование в монтажные области: у выделения нет области @msg Convert to Shape: no rectangles or ellipses selected Преобразование в фигуру: не выделены прямоугольники или эллипсы + Run Выполнить + Run Script Выполнить скрипт + Stop Script Остановить скрипт + Show External Script Warning Показывать предупреждение о внешних скриптах + A Script Is Running Выполняется скрипт + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт может изменять открытые документы и записывать файлы с помощью выполняемых команд. Запускайте только те скрипты, которым доверяете. + Do not show this warning again Больше не показывать это предупреждение + Stop the script and undo what it did? Then quit again. Остановить скрипт и отменить его изменения? Затем выйдите ещё раз. + This script has added a great many objects. Continue, or stop it and undo what it did? Этот скрипт добавил очень много объектов. Продолжить или остановить его и отменить его изменения? +@msg Choose a .js or .jsx script Выберите скрипт .js или .jsx +@msg Ran {name} Выполнено: {name} +@msg The script was stopped and its changes were undone Скрипт остановлен, его изменения отменены +@msg a script is running: stop it or wait for it to finish выполняется скрипт: остановите его или дождитесь завершения +@msg this is a compiled script, which cannot be run; use its source text это скомпилированный скрипт, его нельзя выполнить; используйте его исходный текст +@msg the script is not UTF-8 text скрипт не является текстом UTF-8 +@msg the script is too large to run скрипт слишком велик для выполнения +@msg a drag or an undo group is open; finish it before running a script открыто перетаскивание или группа отмены; завершите их перед запуском скрипта +@msg the script left a document that cannot be kept скрипт оставил документ, который нельзя сохранить +@msg the script's run is no longer available выполнение скрипта больше недоступно +@msg a recovery copy could not be written, so the script was not run не удалось записать копию для восстановления, поэтому скрипт не выполнен +@msg Running a script Выполняется скрипт @msg Copied the script report Отчёт о сценарии скопирован @msg Corners edits the corners of a path «Углы» редактирует углы контура @msg Couldn't copy to the system clipboard: {e} Не удалось скопировать в системный буфер обмена: {e} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a8ce1904d..64e32b2ff 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3978,6 +3978,27 @@ axis Both Båda @msg Combine Slices: the slices have no bounds Kombinera skivor: skivorna har inga gränser @msg Convert to Artboards: selection has no area Konvertera till ritytor: markeringen har ingen area @msg Convert to Shape: no rectangles or ellipses selected Konvertera till form: inga rektanglar eller ellipser är markerade + Run Kör + Run Script Kör skript + Stop Script Stoppa skript + Show External Script Warning Visa varning för externa skript + A Script Is Running Ett skript körs + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ett skript kan ändra de öppna dokumenten och skriva filer genom de kommandon det kör. Kör bara skript du litar på. + Do not show this warning again Visa inte den här varningen igen + Stop the script and undo what it did? Then quit again. Stoppa skriptet och ångra det det gjort? Avsluta sedan igen. + This script has added a great many objects. Continue, or stop it and undo what it did? Det här skriptet har lagt till väldigt många objekt. Fortsätta, eller stoppa det och ångra det det gjort? +@msg Choose a .js or .jsx script Välj ett .js- eller .jsx-skript +@msg Ran {name} Körde {name} +@msg The script was stopped and its changes were undone Skriptet stoppades och dess ändringar ångrades +@msg a script is running: stop it or wait for it to finish ett skript körs: stoppa det eller vänta tills det är klart +@msg this is a compiled script, which cannot be run; use its source text det här är ett kompilerat skript som inte kan köras; använd dess källtext +@msg the script is not UTF-8 text skriptet är inte UTF-8-text +@msg the script is too large to run skriptet är för stort för att köras +@msg a drag or an undo group is open; finish it before running a script en dragning eller ångragrupp är öppen; avsluta den innan du kör ett skript +@msg the script left a document that cannot be kept skriptet lämnade ett dokument som inte kan behållas +@msg the script's run is no longer available skriptets körning är inte längre tillgänglig +@msg a recovery copy could not be written, so the script was not run en återställningskopia kunde inte skrivas, så skriptet kördes inte +@msg Running a script Kör ett skript @msg Copied the script report Kopierade skriptrapporten @msg Corners edits the corners of a path Hörn redigerar hörnen på en bana @msg Couldn't copy to the system clipboard: {e} Kunde inte kopiera till systemets urklipp: {e} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2804006c..b5720eb53 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3860,6 +3860,27 @@ axis Both Her İkisi @msg Combine Slices: the slices have no bounds Dilimleri Birleştir: dilimlerin sınırları yoktur @msg Convert to Artboards: selection has no area Çalışma Yüzeylerine Dönüştür: seçimin alanı yok @msg Convert to Shape: no rectangles or ellipses selected Şekle Dönüştür: seçili dikdörtgen veya elips yok + Run Çalıştır + Run Script Betiği Çalıştır + Stop Script Betiği Durdur + Show External Script Warning Harici Betik Uyarısını Göster + A Script Is Running Bir Betik Çalışıyor + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Bir betik açık belgeleri değiştirebilir ve çalıştırdığı komutlarla dosya yazabilir. Yalnızca güvendiğiniz betikleri çalıştırın. + Do not show this warning again Bu uyarıyı bir daha gösterme + Stop the script and undo what it did? Then quit again. Betik durdurulsun ve yaptıkları geri alınsın mı? Ardından yeniden çıkın. + This script has added a great many objects. Continue, or stop it and undo what it did? Bu betik çok sayıda nesne ekledi. Devam edilsin mi, yoksa durdurulup yaptıkları geri mi alınsın? +@msg Choose a .js or .jsx script Bir .js veya .jsx betiği seçin +@msg Ran {name} {name} çalıştırıldı +@msg The script was stopped and its changes were undone Betik durduruldu ve değişiklikleri geri alındı +@msg a script is running: stop it or wait for it to finish bir betik çalışıyor: durdurun veya bitmesini bekleyin +@msg this is a compiled script, which cannot be run; use its source text bu derlenmiş bir betik, çalıştırılamaz; kaynak metnini kullanın +@msg the script is not UTF-8 text betik UTF-8 metni değil +@msg the script is too large to run betik çalıştırılamayacak kadar büyük +@msg a drag or an undo group is open; finish it before running a script bir sürükleme veya geri alma grubu açık; betiği çalıştırmadan önce tamamlayın +@msg the script left a document that cannot be kept betik, saklanamayan bir belge bıraktı +@msg the script's run is no longer available betiğin çalıştırması artık kullanılamıyor +@msg a recovery copy could not be written, so the script was not run kurtarma kopyası yazılamadı, bu nedenle betik çalıştırılmadı +@msg Running a script Betik çalıştırılıyor @msg Copied the script report Komut dosyası raporu kopyalandı @msg Corners edits the corners of a path Köşeler bir yolun köşelerini düzenler @msg Couldn't copy to the system clipboard: {e} Sistem panosuna kopyalanamadı: {e} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0108c96a5..86680b5eb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4094,6 +4094,27 @@ axis Both Обидві @msg Combine Slices: the slices have no bounds Об’єднання фрагментів: фрагменти не мають кордонів @msg Convert to Artboards: selection has no area Перетворення на монтажні області: виділення не має області @msg Convert to Shape: no rectangles or ellipses selected Перетворення на фігуру: не виділено прямокутники або еліпси + Run Виконати + Run Script Виконати скрипт + Stop Script Зупинити скрипт + Show External Script Warning Показувати попередження про зовнішні скрипти + A Script Is Running Виконується скрипт + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт може змінювати відкриті документи й записувати файли за допомогою команд, які виконує. Запускайте лише ті скрипти, яким довіряєте. + Do not show this warning again Більше не показувати це попередження + Stop the script and undo what it did? Then quit again. Зупинити скрипт і скасувати його зміни? Потім вийдіть ще раз. + This script has added a great many objects. Continue, or stop it and undo what it did? Цей скрипт додав дуже багато об’єктів. Продовжити чи зупинити його та скасувати його зміни? +@msg Choose a .js or .jsx script Виберіть скрипт .js або .jsx +@msg Ran {name} Виконано: {name} +@msg The script was stopped and its changes were undone Скрипт зупинено, його зміни скасовано +@msg a script is running: stop it or wait for it to finish виконується скрипт: зупиніть його або дочекайтеся завершення +@msg this is a compiled script, which cannot be run; use its source text це скомпільований скрипт, його не можна виконати; використайте його вихідний текст +@msg the script is not UTF-8 text скрипт не є текстом UTF-8 +@msg the script is too large to run скрипт завеликий для виконання +@msg a drag or an undo group is open; finish it before running a script відкрито перетягування або групу скасування; завершіть їх перед запуском скрипта +@msg the script left a document that cannot be kept скрипт залишив документ, який не можна зберегти +@msg the script's run is no longer available виконання скрипта більше не доступне +@msg a recovery copy could not be written, so the script was not run не вдалося записати копію для відновлення, тому скрипт не виконано +@msg Running a script Виконується скрипт @msg Copied the script report Звіт про сценарій скопійовано @msg Corners edits the corners of a path «Кути» редагує кути контуру @msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5b979b003..8e95b1edd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4512,6 +4512,27 @@ theme Appearance Giao diện @msg compressed (RLE) bitmaps can't be read yet bitmap nén (RLE) chưa thể đọc được @msg Convert to Artboards: selection has no area Chuyển thành bảng vẽ: vùng chọn không có diện tích @msg Convert to Shape: no rectangles or ellipses selected Chuyển thành hình dạng: chưa chọn hình chữ nhật hay hình elip + Run Chạy + Run Script Chạy tập lệnh + Stop Script Dừng tập lệnh + Show External Script Warning Hiện cảnh báo tập lệnh bên ngoài + A Script Is Running Một tập lệnh đang chạy + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Tập lệnh có thể thay đổi các tài liệu đang mở và ghi tệp thông qua các lệnh mà nó chạy. Chỉ chạy những tập lệnh bạn tin cậy. + Do not show this warning again Không hiện lại cảnh báo này + Stop the script and undo what it did? Then quit again. Dừng tập lệnh và hoàn tác những gì nó đã làm? Sau đó hãy thoát lại. + This script has added a great many objects. Continue, or stop it and undo what it did? Tập lệnh này đã thêm rất nhiều đối tượng. Tiếp tục, hay dừng lại và hoàn tác những gì nó đã làm? +@msg Choose a .js or .jsx script Chọn một tập lệnh .js hoặc .jsx +@msg Ran {name} Đã chạy {name} +@msg The script was stopped and its changes were undone Tập lệnh đã dừng và các thay đổi của nó đã được hoàn tác +@msg a script is running: stop it or wait for it to finish một tập lệnh đang chạy: hãy dừng nó hoặc chờ nó kết thúc +@msg this is a compiled script, which cannot be run; use its source text đây là tập lệnh đã biên dịch, không thể chạy; hãy dùng văn bản nguồn của nó +@msg the script is not UTF-8 text tập lệnh không phải là văn bản UTF-8 +@msg the script is too large to run tập lệnh quá lớn để chạy +@msg a drag or an undo group is open; finish it before running a script đang có thao tác kéo hoặc nhóm hoàn tác; hãy hoàn thành trước khi chạy tập lệnh +@msg the script left a document that cannot be kept tập lệnh để lại một tài liệu không thể giữ +@msg the script's run is no longer available lần chạy tập lệnh không còn khả dụng +@msg a recovery copy could not be written, so the script was not run không thể ghi bản sao khôi phục nên tập lệnh không được chạy +@msg Running a script Đang chạy tập lệnh @msg Copied the script report Đã sao chép báo cáo script @msg Corners edits the corners of a path Corners chỉnh sửa các góc của đường dẫn @msg could not decode image: {0} không thể giải mã ảnh: {0} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d7b40b95d..2bd2f3ade 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4526,6 +4526,27 @@ imageTrace Outline 轮廓 @msg the library item is damaged 库项目已损坏 @msg {_1} font families available 有 {_1} 个可用的字体系列 @msg {_1} has no {_2} style {_1} 没有 {_2} 样式 + Run 运行 + Run Script 运行脚本 + Stop Script 停止脚本 + Show External Script Warning 显示外部脚本警告 + A Script Is Running 脚本正在运行 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 脚本可以更改打开的文档,并可通过其运行的命令写入文件。请只运行您信任的脚本。 + Do not show this warning again 不再显示此警告 + Stop the script and undo what it did? Then quit again. 要停止脚本并撤销其更改吗?然后请再次退出。 + This script has added a great many objects. Continue, or stop it and undo what it did? 此脚本已添加大量对象。要继续,还是停止并撤销其更改? +@msg Choose a .js or .jsx script 请选择 .js 或 .jsx 脚本 +@msg Ran {name} 已运行 {name} +@msg The script was stopped and its changes were undone 脚本已停止,其更改已撤销 +@msg a script is running: stop it or wait for it to finish 脚本正在运行:请停止它或等待其完成 +@msg this is a compiled script, which cannot be run; use its source text 这是已编译的脚本,无法运行;请使用其源文本 +@msg the script is not UTF-8 text 脚本不是 UTF-8 文本 +@msg the script is too large to run 脚本过大,无法运行 +@msg a drag or an undo group is open; finish it before running a script 拖动或撤销组尚未结束;请在运行脚本前完成 +@msg the script left a document that cannot be kept 脚本留下了无法保留的文档 +@msg the script's run is no longer available 脚本运行已不可用 +@msg a recovery copy could not be written, so the script was not run 无法写入恢复副本,因此未运行脚本 +@msg Running a script 正在运行脚本 @msg Copied the script report 已复制脚本报告 @msg Exported {path} 已导出 {path} @msg no CSS to copy: select objects with CSS of their own 没有可复制的 CSS:请选择带有自身 CSS 的对象 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e27d9c7b..65a0bd587 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2370,6 +2370,27 @@ file-filter Templates 範本 @msg the library item is damaged 資料庫項目已損毀 @msg {_1} font families available 有 {_1} 個可用的字體家族 @msg {_1} has no {_2} style {_1} 沒有 {_2} 樣式 + Run 執行 + Run Script 執行指令碼 + Stop Script 停止指令碼 + Show External Script Warning 顯示外部指令碼警告 + A Script Is Running 指令碼正在執行 + A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 指令碼可以變更開啟的文件,並可透過其執行的命令寫入檔案。請只執行您信任的指令碼。 + Do not show this warning again 不再顯示此警告 + Stop the script and undo what it did? Then quit again. 要停止指令碼並還原其變更嗎?然後請再次結束。 + This script has added a great many objects. Continue, or stop it and undo what it did? 此指令碼已新增大量物件。要繼續,還是停止並還原其變更? +@msg Choose a .js or .jsx script 請選擇 .js 或 .jsx 指令碼 +@msg Ran {name} 已執行 {name} +@msg The script was stopped and its changes were undone 指令碼已停止,其變更已還原 +@msg a script is running: stop it or wait for it to finish 指令碼正在執行:請停止它或等待其完成 +@msg this is a compiled script, which cannot be run; use its source text 這是已編譯的指令碼,無法執行;請使用其原始文字 +@msg the script is not UTF-8 text 指令碼不是 UTF-8 文字 +@msg the script is too large to run 指令碼過大,無法執行 +@msg a drag or an undo group is open; finish it before running a script 拖曳或還原群組尚未結束;請在執行指令碼前完成 +@msg the script left a document that cannot be kept 指令碼留下了無法保留的文件 +@msg the script's run is no longer available 指令碼執行已無法使用 +@msg a recovery copy could not be written, so the script was not run 無法寫入復原副本,因此未執行指令碼 +@msg Running a script 正在執行指令碼 @msg Copied the script report 已複製指令碼報告 @msg Exported {path} 已匯出 {path} @msg no CSS to copy: select objects with CSS of their own 沒有可複製的 CSS:請選取有自己 CSS 的物件 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 03acb7597..d6c91d3d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -65,6 +65,7 @@ pub mod print; pub mod recovery; pub mod render_worker; pub mod screen_color; +pub mod script_runs; mod scrub; pub mod shortcut_editor; pub mod shortcuts; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 9ec98faae..92186e9f3 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -969,6 +969,7 @@ fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(crate::trace_runs::cancel_all(app)), "imageTrace.hideRuns" => Ok(crate::trace_runs::hide_all(app)), "ui.checkScript" => crate::dialogs::script_check::command(app, p), + "ui.runScript" => crate::dialogs::script_run::command(app, p), "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), "ui.perspectivePresetsDialog" => { crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index c51f36e7d..ebea83e90 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -62,7 +62,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Export Selection…", "file.exportSelection"), Sep, c("Package…", "ui.packageDialog"), - sub("Scripts", vec![c("Check Script…", "ui.checkScript"), todos("Other Script…", "Cmd+F12")]), + sub("Scripts", vec![c("Check Script…", "ui.checkScript"), c("Other Script…", "ui.runScript")]), Sep, c("Document Setup…", "file.documentSetup"), sub( diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 3b314f833..fb78fc207 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1520,6 +1520,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} close the progress window of the Image Traces running in the background; they go on, shown in the status bar → {hidden: n}", ), + ( + "ui.runScript", + "Other Script…", + "Cmd+F12", + "{path? | source?, name?} run a .js or .jsx file (script.run), after the warning unless Preferences › General › Show External Script Warning is off (dialog `scriptRun`: `name`, `script`, `dontShow`); without a file pick one (the web opens the browser's file picker)", + ), ( "ui.checkScript", "Check Script…", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 28a4d9d11..4122c1941 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -72,6 +72,7 @@ const DOCUMENT_FREE: &[&str] = &[ "file.newFromTemplate", "ui.installPlugin", "ui.checkScript", + "ui.runScript", "window.swatchLibrary.other", "window.graphicStyleLibrary.other", "ui.libraryPanel.locate", diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs new file mode 100644 index 000000000..d88c0bf1b --- /dev/null +++ b/crates/ui-egui/src/script_runs.rs @@ -0,0 +1,44 @@ +//! A running script, advanced one slice per frame: `script.run` starts it and [`poll`] takes it to +//! its end, while the app goes on drawing the documents as they were before the run. The status bar +//! shows it with a Stop button; a run that has added a great deal waits for the person's answer. + +use serde_json::json; + +use crate::VectorcraftApp; +use crate::dialogs::script_run; +use crate::state::Dialog; + +/// Advances the running script by one slice, or asks what its growth needs asked. +pub fn poll(app: &mut VectorcraftApp) { + script_run::drain(app); + if !app.session.script_running() { + return; + } + if app.session.script_waiting() { + if app.ui.dialog.is_none() { + app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({}))); + } + return; + } + let name = app.session.script_name().unwrap_or_default(); + let Some(result) = app.session.script_pump() else { return }; + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::GROWTH_KIND) { + app.ui.dialog = None; + } + match result { + Ok(_) => app.status(format!("Ran {name}")), + Err(e) => failed(app, e.to_string()), + } + app.sync_views(); +} + +/// Shows why a run ended without its changes. A stopped run that wrote no file says only that it +/// was stopped; any other failure shows its error, which names the files left written. +pub(crate) fn failed(app: &mut VectorcraftApp, error: String) { + let stopped = app.session.script_report().is_some_and(|r| r["kind"] == "stopped" && r["written"].as_array().is_none_or(Vec::is_empty)); + if stopped { + app.status("The script was stopped and its changes were undone"); + } else { + app.status(error); + } +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 47374c7e6..901c0a340 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -107,6 +107,11 @@ pub struct Services { /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the /// canvas). pub place_inbox: Option, + /// Web: start an async pick of a script to run (it arrives via `script_inbox`; a canceled pick + /// delivers nothing). + pub script_async: Option>, + /// Scripts the web's picker delivered for File › Scripts › Other Script…. + pub script_inbox: Option, /// The system clipboard with all its formats (desktop): Copy offers text, SVG, PDF and PNG, /// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through /// egui and `clipboard_read`). diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 2593115f6..261a25054 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -39,6 +39,7 @@ mod saveext; mod screenmode; mod screenshots; mod script_check; +mod script_run; mod scrub; mod selectall; mod selectescape; diff --git a/crates/ui-egui/src/tests/script_check.rs b/crates/ui-egui/src/tests/script_check.rs index ccaa6c7a8..0ff703eb3 100644 --- a/crates/ui-egui/src/tests/script_check.rs +++ b/crates/ui-egui/src/tests/script_check.rs @@ -46,7 +46,7 @@ fn file_scripts_holds_check_script_above_other_script() { let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap(); let scripts = file.iter().find_map(|i| if let menus::Item::Sub("Scripts", items) = i { Some(items) } else { None }).unwrap(); assert!( - matches!(scripts.as_slice(), [menus::Item::Cmd("Check Script…", "ui.checkScript", _), menus::Item::Todo("Other Script…", _)]), + matches!(scripts.as_slice(), [menus::Item::Cmd("Check Script…", "ui.checkScript", _), menus::Item::Cmd("Other Script…", "ui.runScript", _)]), "{scripts:?}" ); } diff --git a/crates/ui-egui/src/tests/script_run.rs b/crates/ui-egui/src/tests/script_run.rs new file mode 100644 index 000000000..08ac8e191 --- /dev/null +++ b/crates/ui-egui/src/tests/script_run.rs @@ -0,0 +1,283 @@ +//! File › Scripts › Other Script… (`ui.runScript`): the chooser, the warning and its preference, +//! a run advanced frame by frame, Stop, and the questions a run and quitting ask. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, dialogs, menus, script_runs}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app +} + +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vc-script-run-ui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +fn finish(app: &mut VectorcraftApp) { + for _ in 0..10_000 { + if !app.session.script_running() { + return; + } + script_runs::poll(app); + } + panic!("the script did not end"); +} + +const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10}); vc.run('shape.rectangle', {x:20,y:0,width:10,height:10});"; + +fn confirm(app: &mut VectorcraftApp) -> Result { + dialogs::confirm(app) +} + +#[test] +fn the_warning_comes_before_the_run_and_cancel_runs_nothing() { + let mut app = app(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": DRAW, "name": "draw.js"})).unwrap().unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["name"].as_str()), (dialogs::script_run::KIND, Some("draw.js"))); + assert!(!app.session.script_running()); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert!(!app.session.script_running()); + finish(&mut app); + assert_eq!(objects(&app), 0); +} + +#[test] +fn a_run_goes_frame_by_frame_and_is_one_undo_step() { + let mut app = app(); + let dir = folder("draw"); + let path = dir.join("Dessin (été) #1.JSX"); + std::fs::write(&path, DRAW).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"path": path.to_string_lossy()})).unwrap().unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["name"], "Dessin (été) #1.JSX"); + confirm(&mut app).unwrap(); + assert!(app.session.script_running()); + assert_eq!(objects(&app), 0, "nothing shows until the run commits"); + finish(&mut app); + assert_eq!(objects(&app), 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(app.ui.status, "Ran Dessin (été) #1.JSX"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(objects(&app), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn do_not_show_again_is_remembered_and_the_script_still_runs() { + let mut app = app(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": DRAW})).unwrap().unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("dontShow".into(), json!(true)); + confirm(&mut app).unwrap(); + finish(&mut app); + assert!(!app.session.prefs.show_external_script_warning); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": DRAW})).unwrap().unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(app.session.script_running()); + finish(&mut app); + assert_eq!(objects(&app), 4); +} + +#[test] +fn only_js_and_jsx_files_are_taken() { + let mut app = app(); + for name in ["a.txt", "a.jsxbin", "a"] { + let e = menus::run_ui_command(&mut app, "ui.runScript", &json!({ "path": name })).unwrap().unwrap_err(); + assert!(e.contains(".js or .jsx"), "{name}: {e}"); + } + assert!(app.ui.dialog.is_none()); +} + +#[test] +fn stop_puts_the_document_back_and_says_so() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + let source = format!("{DRAW} while (true) {{}}"); + menus::run_ui_command(&mut app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(&mut app); + assert!(app.session.script_running()); + assert!(app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap_err().contains("a script is running")); + app.run("script.stop", json!({})).unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn a_script_error_goes_to_the_status_bar() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": "throw new Error('nope');", "name": "x.js"})).unwrap().unwrap(); + finish(&mut app); + assert_eq!(app.ui.status, "x.js: line 1, column 1: Error: nope (thrown)"); +} + +/// An app whose script picker is the web's: picks are counted and files arrive through its inbox. +fn app_with_picker() -> (VectorcraftApp, dialogs::script_run::ScriptInbox, std::rc::Rc>) { + let mut app = app(); + let inbox = dialogs::script_run::ScriptInbox::default(); + let picks = std::rc::Rc::new(std::cell::Cell::new(0)); + let counted = picks.clone(); + app.services.script_async = Some(Box::new(move || counted.set(counted.get() + 1))); + app.services.script_inbox = Some(inbox.clone()); + (app, inbox, picks) +} + +#[test] +fn a_canceled_web_pick_does_not_take_a_dropped_script() { + let (mut app, _inbox, picks) = app_with_picker(); + menus::run_ui_command(&mut app, "ui.runScript", &json!({})).unwrap().unwrap(); + assert_eq!(picks.get(), 1); + // A pick that was canceled delivers nothing, and the next file that opens is an ordinary open. + script_runs::poll(&mut app); + let _ = crate::io::open_bytes(&mut app, "dropped.js", DRAW.as_bytes(), None); + assert!(app.ui.dialog.as_ref().is_none_or(|d| d.kind != dialogs::script_run::KIND)); + assert!(!app.session.script_running()); + assert_eq!(objects(&app), 0); +} + +#[test] +fn a_web_pick_goes_to_the_warning_and_waits_for_an_open_dialog() { + let (mut app, inbox, _picks) = app_with_picker(); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, json!({}))); + inbox.lock().unwrap().push(("picked.js".to_string(), Some(DRAW.as_bytes().to_vec()))); + script_runs::poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, dialogs::script_run::CLOSE_KIND); + assert_eq!(inbox.lock().unwrap().len(), 1, "the file waits"); + app.ui.dialog = None; + script_runs::poll(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["name"].as_str()), (dialogs::script_run::KIND, Some("picked.js"))); + confirm(&mut app).unwrap(); + finish(&mut app); + assert_eq!(objects(&app), 2); +} + +#[test] +fn a_web_pick_that_is_not_text_is_reported() { + let (mut app, inbox, _picks) = app_with_picker(); + inbox.lock().unwrap().push(("bad.js".to_string(), Some(vec![0xff, 0xfe]))); + script_runs::poll(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "the script is not UTF-8 text"); +} + +#[test] +fn a_web_pick_too_large_to_read_is_reported() { + let (mut app, inbox, _picks) = app_with_picker(); + inbox.lock().unwrap().push(("big.js".to_string(), None)); + script_runs::poll(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "the script is too large to run"); +} + +/// A run that never ends, with the growth question up. +fn at_the_growth_question(app: &mut VectorcraftApp) { + app.session.prefs.show_external_script_warning = false; + let source = format!("{DRAW} while (true) {{}}"); + menus::run_ui_command(app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(app); + assert!(app.session.script_running()); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({}))); +} + +#[test] +fn the_growth_questions_stop_button_stops_the_run() { + let mut app = app(); + at_the_growth_question(&mut app); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + finish(&mut app); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn escape_on_the_growth_question_stops_the_run() { + let mut app = app(); + at_the_growth_question(&mut app); + let key = egui::Key::Escape; + let events = vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + output.textures_delta.clear(); + assert!(app.ui.dialog.is_none()); + finish(&mut app); + assert_eq!(objects(&app), 0); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn a_stopped_run_closes_its_growth_question() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": "while (true) {}"})).unwrap().unwrap(); + script_runs::poll(&mut app); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({}))); + app.run("script.stop", json!({})).unwrap(); + finish(&mut app); + assert!(app.ui.dialog.is_none()); +} + +#[test] +fn finished_background_work_waits_for_the_run_to_end() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + app.opening.activate.push(("no such document".to_string(), vectorcraft_engine::file_access::ScopeHandle::capture())); + menus::run_ui_command(&mut app, "ui.runScript", &json!({"source": "while (true) {}"})).unwrap().unwrap(); + app.poll_finished_work(); + assert_eq!(app.opening.activate.len(), 1); + app.run("script.stop", json!({})).unwrap(); + finish(&mut app); + app.poll_finished_work(); + assert!(app.opening.activate.is_empty()); +} + +#[test] +fn quitting_asks_to_stop_and_stop_undoes_the_run() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + let source = format!("{DRAW} while (true) {{}}"); + menus::run_ui_command(&mut app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(&mut app); + assert!(app.session.script_running()); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, json!({}))); + confirm(&mut app).unwrap(); + assert!(!app.session.script_running()); + assert_eq!(objects(&app), 0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, "The script was stopped and its changes were undone"); +} + +#[test] +fn a_stop_names_the_files_the_run_left_written() { + let mut app = app(); + app.session.prefs.show_external_script_warning = false; + let dir = folder("written"); + let path = dir.join("out.svg"); + let p = path.to_string_lossy().into_owned(); + let source = format!("vc.run('document.export', {{path: {p:?}, format: 'svg'}}); while (true) {{}}"); + menus::run_ui_command(&mut app, "ui.runScript", &json!({ "source": source })).unwrap().unwrap(); + script_runs::poll(&mut app); + assert!(path.exists()); + app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, json!({}))); + confirm(&mut app).unwrap(); + assert!(app.ui.status.contains(&p), "{}", app.ui.status); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_new_app_shows_the_warning_by_default() { + assert!(Session::new().prefs.show_external_script_warning); +} diff --git a/docs/gaps.md b/docs/gaps.md index 0939dbf3d..5372fb46d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -690,8 +690,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript, File › Scripts; - #1183 is open). Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and +- **Missing:** the document object model for scripts, a script console, the Scripts folder and script + directives (#1183 is open). File › Scripts › Other Script… and `script.run` run JavaScript files over the + command registry. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and export, and full agent control over MCP are done. - **Estimate:** 4–8 h. - **Done (2026-10-16):** the MCP server hears the client during a long call: `notifications/cancelled` stops a queued or running export (PDF pages, flattening, baking, drawing, artboards) without writing anything or changing the document, `ping` and the list methods answer at once, any other request that would wait behind a call running for two seconds or more is refused as busy (`-32003`, naming the call), and `_meta.progressToken` gets page progress (#2165, #2171, #2172). Reading the document during an export is not served (refused as busy); estimate unchanged. diff --git a/docs/mcp.md b/docs/mcp.md index 81dd91344..127e3ccdc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1452,6 +1452,74 @@ the included files too. vectorcraft-cli run --cmd script.check --params '{"path": "Tidy Layers.jsx"}' ``` +`script.run {path? | source?, name?, args?, answers?, maxFuel?}` runs a JavaScript file against the open documents. +File › Scripts › Other Script… (Cmd+F12) picks a `.js` or `.jsx` file and runs it the same way; the help page +https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html +describes that menu item. Pass exactly one of `path` and `source`. A `path` must end in `.js` or `.jsx`, in any +case. A `.jsxbin` file or text that starts with `@JSXBIN@` is compiled and ends with an error, as does text that is +not UTF-8. The language is ES5.1. The script reaches the app through one global object: + +- `vc.run(id, params)` runs a command and returns its result. A command that fails throws an error the script can + catch. The documents and the session state the command changed, preferences included, go back, and any file it + wrote stays written and is named in the report. A result too large to cross throws a `RangeError`, and the + command's changes go back the same way. +- `vc.document` is a snapshot of the active document, or `null` when none is open. A snapshot too large to cross + throws a `RangeError`. +- `vc.commands` lists the commands a script may run. +- `vc.log(value)` adds a value to the report. +- `vc.args` is the `args` object. +- `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature. + +The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A +run that changes no document adds no undo step. The journal records each command the run finished, as it records +commands run one at a time. A preference the run changed takes the run's value, and a preference changed in the app +while the run went on keeps that change. On an uncaught error, a limit or `script.stop`, every open document and the +session go back to their state before the run. A script can write files through the commands it runs, such as +export commands. A file written before the run failed stays on disk, and the error names it. A run from the menu +shows a warning first, unless you ticked Do not show this warning again or set the preference +`showExternalScriptWarning` to false. + +While a script runs, every other command that changes anything ends with the error `a script is running: stop it or +wait for it to finish`. MCP reports that error as JSON-RPC error `-32003`. `script.stop {}` stops the run and +returns `{stopped}`. `notifications/cancelled` stops a run only while the `tools/call` that started it is waiting +for it, as in a headless server; use `script.stop` for a run the app started. A script cannot run commands that +change anything outside the documents: `script.*` except `script.check`, `plugin.*`, `prefs.*`, `actionSet.*`, +`shortcutSet.*`, `help.*`, `print.*`, `library.*`, `importMatches.*`, `edit.undoGroup.*`, `file.recovery.*`, +`stroke.widthProfile.*`, saving, importing or deleting a preset, `text.addFontFiles`, `text.findFontFiles`, +`text.rescanFonts`, `links.findFiles`, `file.place.queue`, `color.loadProfile`, `color.removeProfile`, `file.print`, +`view.proofSetup`, `view.proofColors`, `view.overprintPreview` and `view.separationsPreview`. Calling one throws an +error. With Data Recovery off for complex documents, a run in the web build does not start while a complex document +has unsaved changes. + +VectorCraft sets these limits itself: + +| Limit | Value | +|-------|-------| +| Source text | 8 MiB | +| Heap | 256 MiB (128 MiB on the web) | +| Host calls | 2,000,000 | +| `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default | +| Instructions without a pause | 200,000,000 | +| Nesting depth | 64 levels | +| A value crossing to or from a command | 16 MiB and 1,048,576 entries | +| `args` as JSON | 1 MiB | +| Log | 10,000 entries and 4 MiB | +| Growth before a person is asked | 100,000 objects and 512 MiB of new document data | + +In the app, a script past the growth limit stops at a question to continue or to stop and undo. Stop, Escape and +`ui.dialog.cancel` all stop the run. A run with nobody to ask, such as a call over MCP or the command line, ends +with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls}`. +In the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. +`answers` takes only an empty object, since scripts have no prompts yet. + +```json +{"name":"run_command","arguments":{"command":"script.run","params":{"source":"vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10});"}}} +``` + +```sh +vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args": {"margin": 12}}' +``` + ## Transparency and opacity masks Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage diff --git a/docs/roadmap.md b/docs/roadmap.md index 19369b9fa..bde1bd308 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -151,7 +151,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | -| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | +| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files; the script document model and console and batch work remain | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c37d5234c..a238d5b96 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -704,6 +704,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: a type object holds one text variable. `variable.bind` and Make Text Dynamic refuse a second one and name the text variable the object has; a visibility variable can still share the object, and a document saved with two text variables on one object opens with both. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. + - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).