Merge pull request #3969 from dotCooCoo/fix/save-file-waits-for-app-save

MCP: save_file against the running app waits for the save and reports its result
This commit is contained in:
BFlat
2026-10-24 19:04:17 +07:00
committed by GitHub
8 changed files with 293 additions and 11 deletions
+36
View File
@@ -166,6 +166,42 @@ fn exporting_native_keeps_the_document_path() {
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
}
/// The app is asked to write the file before it replies (`wait`), even with Background Save on, so a
/// save that fails is the call's error; a headless session always does, and is sent no `wait`.
#[test]
fn save_file_waits_for_the_file_in_the_app() {
struct Recording {
ui: bool,
seen: Vec<(String, Value)>,
}
impl Backend for Recording {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.seen.push((method.to_string(), params));
if self.ui { Err("Saving a.vectorcraft failed: disk full".into()) } else { Ok(json!({})) }
}
fn has_ui(&self) -> bool {
self.ui
}
fn describe(&self) -> String {
"recording".into()
}
}
for ui in [true, false] {
let mut b = Recording { ui, seen: vec![] };
let r = call_tool(&mut b, "save_file", &json!({"path": "/out/a.vectorcraft"}));
assert_eq!(r.is_error, ui, "the app's error is the call's: {r:?}");
let [(method, params)] = &b.seen[..] else { panic!("one call: {:?}", b.seen) };
assert_eq!(method, "app.save");
// The tool sends the path made absolute (on Windows, against the current drive).
let path = std::path::absolute("/out/a.vectorcraft").unwrap().to_string_lossy().into_owned();
let mut want = json!({"path": path});
if ui {
want["wait"] = json!(true);
}
assert_eq!(params, &want, "has_ui: {ui}");
}
}
fn pdf_pages(pdf: &[u8]) -> usize {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
+10 -2
View File
@@ -354,7 +354,7 @@ pub fn tool_definitions() -> Vec<Value> {
"save_file",
"Save file",
&format!(
"Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). The reply's `format` says what was written and `warnings` what that format loses.",
"Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). The reply's `format` says what was written and `warnings` what that format loses. In the running app the call returns once the file is written, or with the error, even with Background Save on. When the save asks first (before replacing a file that opened with losses, or with a file dialog for an untitled document without `path`), the reply is `pending` or \"cancelled\" as before.",
save_extensions().join(", .")
),
obj(json!({"path": string("Destination; its extension picks the format (default: the document's own file)")}), &[]),
@@ -1022,7 +1022,15 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
}
"screenshot" => screenshot(b, a),
"open_file" => j(b.call("app.open", json!({"path": absolute_path(req_str(a, "path")?)?}))?),
"save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str).map(absolute_path).transpose()?}))?),
"save_file" => {
let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(absolute_path).transpose()?});
// The app writes the file before it replies, even with Background Save on, so a failed
// save is this call's error.
if b.has_ui() {
p["wait"] = json!(true);
}
j(b.call("app.save", p)?)
}
"export" => {
// One params object for every backend; the named arguments win over `options` (a named
// artboard or range replaces every artboard choice in `options`).
+15 -1
View File
@@ -77,6 +77,8 @@ pub struct Background {
/// Jobs in the order they were started (the worker runs them in that order).
pub jobs: Vec<Job>,
next_id: u64,
/// Work started while this is set runs at once, whatever the preferences ([`in_foreground`]).
foreground: bool,
}
impl Background {
@@ -159,7 +161,7 @@ pub fn run_with_progress(
then: impl FnOnce(&mut VectorcraftApp, Result<Value, String>) -> Result<Value, String> + 'static,
) -> Result<Value, String> {
let mut work: Work = Box::new(work);
let shared = app.services.write_shared.clone().filter(|_| enabled && app.services.download.is_none());
let shared = app.services.write_shared.clone().filter(|_| enabled && !app.background.foreground && app.services.download.is_none());
if let Some(write) = shared {
let id = app.background.next_id;
if let Some(tx) = app.background.worker() {
@@ -188,6 +190,18 @@ pub fn run_with_progress(
then(app, r)
}
/// Run `f` with every save and export it starts written at once, as with the preferences off, so
/// its result is the written file or the error (a control client's `wait: true`). The jobs already
/// running finish first, so none of them writes over its files afterwards.
pub fn in_foreground<T>(app: &mut VectorcraftApp, f: impl FnOnce(&mut VectorcraftApp) -> T) -> T {
wait_all(app);
let was = std::mem::replace(&mut app.background.foreground, true);
// A panic caught further up (a control request's guard) still turns the setting back.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(app)));
app.background.foreground = was;
r.unwrap_or_else(|e| std::panic::resume_unwind(e))
}
/// Apply the results that arrived (each frame).
pub fn poll(app: &mut VectorcraftApp) {
while let Some(done) = app.background.worker.as_ref().and_then(|(_, rx)| rx.try_recv().ok()) {
+29 -6
View File
@@ -24,6 +24,8 @@
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
//! - `wait: true` on `app.save` and `app.export`: the file is written before the reply, even with
//! Background Save or Background Export on. A `wait` other than true, false or null is refused
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
@@ -453,12 +455,20 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
}
}
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
"app.save" => wrap(app.run("file.save", if p.is_object() { p.clone() } else { json!({}) })),
"app.export" => match s("path") {
Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p)),
// No save dialog for an agent: the bytes come back, as in headless mode.
None => wrap(app.run("document.export", p.clone())),
},
"app.save" | "app.export" => {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
// `wait: true`: the file is written before the reply, whatever Background Save and
// Background Export are set to, so the reply is the written file or the error.
let wait = match crate::menus::opt_bool(&p, "wait") {
Ok(w) => w == Some(true),
Err(e) => return err(e),
};
if let Some(o) = p.as_object_mut() {
o.remove("wait");
}
let method = req.method.as_str();
wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) })
}
// As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked
// about; otherwise null, and the host closes the window (#830).
"app.quit" => wrap(app.run("app.quit", json!({}))),
@@ -466,6 +476,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
}
}
/// `app.save` (`method`) or `app.export` with params `p`.
fn save_or_export(app: &mut VectorcraftApp, method: &str, p: &Value) -> Result<Value, String> {
if method == "app.save" {
return app.run("file.save", p.clone());
}
let s = |k: &str| p.get(k).and_then(Value::as_str);
match s("path") {
Some(path) => crate::io::export(app, s("format"), Some(path.to_string()), p),
// No save dialog for an agent: the bytes come back, as in headless mode.
None => app.run("document.export", p.clone()),
}
}
/// `result` with the PNG as `pngBase64` when there is one.
fn with_data(mut result: Value, png: Option<&[u8]>) -> Value {
if let Some(png) = png {
+32
View File
@@ -8,12 +8,19 @@ use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_engine::file_access::AutomationRoots;
use crate::background::SharedWriteFn;
use crate::control::ControlRequest;
use crate::{Services, VectorcraftApp};
/// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside`
/// (with `b.svg`), and an app with the desktop's file services confined to `in` and `out`.
fn confined(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, PathBuf) {
confined_with(tag, None)
}
/// [`confined`], with `write_shared` as the thread-safe writer that Background Save and Background
/// Export use.
fn confined_with(tag: &str, write_shared: Option<SharedWriteFn>) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, PathBuf) {
let base = std::env::temp_dir().join(format!("vc-ui-roots-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in", "out", "outside"] {
@@ -25,6 +32,7 @@ fn confined(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlReques
let services = Services {
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| vectorcraft_format::write_atomic(Path::new(p), b).map_err(|e| e.to_string()))),
write_shared,
..Default::default()
};
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
@@ -77,6 +85,30 @@ fn control_requests_stay_inside_the_roots() {
let _ = std::fs::remove_dir_all(base);
}
/// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true`
/// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it
/// before they reply. Without `wait` the refusal reaches only the status bar.
#[test]
fn control_saves_with_wait_reply_with_the_write_roots_refusal() {
let shared: SharedWriteFn = std::sync::Arc::new(|p: &str, b: &[u8]| vectorcraft_format::write_atomic(Path::new(p), b).map_err(|e| e.to_string()));
let (mut app, tx, base) = confined_with("wait", Some(shared));
app.run("file.new", json!({"width": 100, "height": 50})).unwrap();
for (method, name) in [("app.save", "a.vectorcraft"), ("app.export", "a.svg")] {
let r = request(&mut app, &tx, method, json!({"path": s(base.join("outside").join(name)), "wait": true}));
assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root")), "{method}: {r}");
let path = base.join("out").join(name);
let r = request(&mut app, &tx, method, json!({"path": s(path.clone()), "wait": true}));
assert_eq!(r["ok"], true, "{method}: {r}");
assert!(r["result"].get("background").is_none() && path.is_file(), "{method}: written before the reply: {r}");
}
let r = request(&mut app, &tx, "app.save", json!({"path": s(base.join("outside/b.vectorcraft"))}));
assert_eq!(r["result"]["background"], true, "{r}");
crate::background::wait_all(&mut app);
assert!(app.ui.status.contains("outside the write root"), "{}", app.ui.status);
assert_eq!(std::fs::read_dir(base.join("outside")).unwrap().count(), 1, "nothing written outside");
let _ = std::fs::remove_dir_all(base);
}
/// A shortcut set another app saved (`.kys`) is read only from inside the read root, whichever way a
/// control request names it: `shortcutSet.read`, Import Shortcut Set, Keyboard Shortcuts' Import…
/// and File › Open.
+169
View File
@@ -9,6 +9,7 @@ use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_engine::Session;
use vectorcraft_geom::{Rect, shapes};
use crate::control::ControlRequest;
use crate::{Services, VectorcraftApp, background, io};
pub(super) type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
@@ -201,6 +202,174 @@ fn the_status_bar_shows_progress() {
assert!(done.iter().any(|t| t == "Exported /out/p.svg") && !done.iter().any(|t| t.contains('…')), "{done:?}");
}
/// An app with a control channel: its UI-thread writer records files (or fails with `fail`), and
/// its thread-safe writer does the same once `gate` opens.
fn controlled(gate: Gate, fail: Option<&'static str>) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, Files) {
controlled_with(gate, fail, None)
}
/// [`controlled`], with `wrote` told each time the UI-thread writer has recorded a file.
fn controlled_with(
gate: Gate,
fail: Option<&'static str>,
wrote: Option<std::sync::mpsc::Sender<()>>,
) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, Files) {
let files = Files::default();
let (f, g) = (files.clone(), files.clone());
let services = Services {
write: Some(Box::new(move |p: &str, b: &[u8]| match fail {
Some(e) => Err(e.into()),
None => {
f.lock().unwrap().push((p.to_string(), b.to_vec()));
if let Some(w) = &wrote {
let _ = w.send(());
}
Ok(())
}
})),
write_shared: Some(Arc::new(move |p: &str, b: &[u8]| {
gate.wait();
if let Some(e) = fail {
return Err(e.into());
}
g.lock().unwrap().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
(app, tx, files)
}
/// One request through the control channel, as the app's frame drains it.
fn request(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender<ControlRequest>, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params);
tx.send(req).unwrap();
app.drain_control(&egui::Context::default());
reply.try_recv().unwrap()
}
/// `app.save` and `app.export` with `wait: true` (MCP's `save_file` in the app) write the file before
/// they reply, with Background Save and Background Export on; without it they still reply at once.
#[test]
fn control_saves_and_exports_with_wait_reply_once_written() {
let gate = Gate::default();
let (mut app, tx, files) = controlled(gate.clone(), None);
let r = request(&mut app, &tx, "app.save", json!({"path": "/docs/w.vectorcraft", "wait": true}));
assert_eq!(r["ok"], true, "{r}");
assert!(r["result"].get("background").is_none() && r["result"]["path"] == "/docs/w.vectorcraft", "{r}");
assert_eq!(written(&files), ["/docs/w.vectorcraft"], "written before the reply");
let st = app.session.doc().unwrap();
assert!(!st.is_dirty() && st.path.as_deref() == Some("/docs/w.vectorcraft"));
assert_eq!(app.ui.status, "Saved /docs/w.vectorcraft");
let r = request(&mut app, &tx, "app.export", json!({"path": "/out/w.svg", "wait": true}));
assert_eq!(r["ok"], true, "{r}");
assert!(r["result"].get("background").is_none() && r["result"]["path"] == "/out/w.svg", "{r}");
assert_eq!(written(&files), ["/docs/w.vectorcraft", "/out/w.svg"]);
assert!(app.background.jobs.is_empty());
for (method, path) in [("app.save", "/docs/later.vectorcraft"), ("app.export", "/out/later.svg")] {
let r = request(&mut app, &tx, method, json!({"path": path, "wait": false}));
assert_eq!(r["result"]["background"], true, "{method}: {r}");
}
assert_eq!(written(&files).len(), 2, "the gate holds the worker");
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), ["/docs/w.vectorcraft", "/out/w.svg", "/docs/later.vectorcraft", "/out/later.svg"]);
}
/// An export with `wait: true` to the file a background export is still writing replies after both
/// are written, so the older one doesn't write over it afterwards.
#[test]
fn control_exports_with_wait_follow_the_jobs_already_running() {
let gate = Gate::default();
let (wrote, waiting_wrote) = std::sync::mpsc::channel();
let (mut app, tx, files) = controlled_with(gate.clone(), None, Some(wrote));
let r = request(&mut app, &tx, "app.export", json!({"path": "/out/same.svg"}));
assert_eq!(r["result"]["background"], true, "{r}");
// The waiting export draws one more rectangle than the background export's snapshot.
app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 30, "height": 30})).unwrap();
// The background export is let go as soon as the waiting export has written its file, or after
// a second while the waiting export waits for it.
let opener = gate.clone();
let release = std::thread::spawn(move || {
let _ = waiting_wrote.recv_timeout(std::time::Duration::from_secs(1));
opener.open();
});
let r = request(&mut app, &tx, "app.export", json!({"path": "/out/same.svg", "wait": true}));
assert_eq!(r["ok"], true, "{r}");
assert!(app.background.jobs.is_empty(), "the background export finished before the reply");
release.join().unwrap();
background::wait_all(&mut app);
let both = files.lock().unwrap().clone();
assert_eq!(written(&files), ["/out/same.svg", "/out/same.svg"]);
assert_ne!(both[0].1, both[1].1, "the two exports draw different art");
let r = request(&mut app, &tx, "app.export", json!({"path": "/out/check.svg", "wait": true}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(files.lock().unwrap()[2].1, both[1].1, "the waiting export wrote last");
}
/// A panic during a save with `wait: true`, caught further up, leaves Background Save on.
#[test]
fn a_panic_while_waiting_leaves_background_saves_on() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| background::in_foreground(&mut app, |_| panic!("an encoder bug"))));
assert!(r.is_err());
let r = app.run("file.save", json!({"path": "/docs/after.vectorcraft"})).unwrap();
assert_eq!(r["background"], true, "{r}");
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), ["/docs/after.vectorcraft"]);
}
/// A save or export with `wait: true` that fails replies with the error.
#[test]
fn control_saves_and_exports_with_wait_reply_with_the_error() {
let gate = Gate::default();
gate.open();
let (mut app, tx, files) = controlled(gate, Some("disk full"));
for (method, path) in [("app.save", "/docs/f.vectorcraft"), ("app.export", "/out/f.svg")] {
let r = request(&mut app, &tx, method, json!({"path": path, "wait": true}));
assert_eq!(r["ok"], false, "{method}: {r}");
assert!(r["error"].as_str().unwrap().contains("disk full"), "{method}: {r}");
}
assert!(app.session.doc().unwrap().path.is_none(), "the document keeps no file");
// Without it the reply comes first, and the failure only reaches the status bar.
let r = request(&mut app, &tx, "app.save", json!({"path": "/docs/f.vectorcraft"}));
assert_eq!(r["result"]["background"], true, "{r}");
background::wait_all(&mut app);
assert!(written(&files).is_empty());
}
/// A `wait` that is not true or false is refused before anything is saved or exported; null is the
/// same as leaving it out.
#[test]
fn control_saves_and_exports_refuse_a_wait_that_is_not_a_boolean() {
let gate = Gate::default();
let (mut app, tx, files) = controlled(gate.clone(), None);
for (method, path) in [("app.save", "/docs/y.vectorcraft"), ("app.export", "/out/y.svg")] {
for wait in [json!("yes"), json!(1), json!({})] {
let r = request(&mut app, &tx, method, json!({"path": path, "wait": wait}));
assert_eq!(r["ok"], false, "{method} {wait}: {r}");
assert_eq!(r["error"], "`wait` must be true or false", "{method} {wait}: {r}");
}
}
assert!(app.background.jobs.is_empty() && written(&files).is_empty(), "nothing saved or exported");
assert!(app.session.doc().unwrap().path.is_none());
for (method, path) in [("app.save", "/docs/n.vectorcraft"), ("app.export", "/out/n.svg")] {
let r = request(&mut app, &tx, method, json!({"path": path, "wait": null}));
assert_eq!(r["result"]["background"], true, "{method}: {r}");
}
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), ["/docs/n.vectorcraft", "/out/n.svg"]);
}
#[test]
fn bad_options_fail_before_going_to_the_background() {
let (mut app, files) = app(Gate::default());
+1 -1
View File
@@ -118,7 +118,7 @@ the two can still win that race.
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` |
| Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog |
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written |
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written |
| `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes |
| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) |
| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog |
+1 -1
View File
@@ -399,7 +399,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `window:true` captures the app window (remote only). With `path` the backend (the headless session or the app) writes the PNG there too. |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). The app is sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |