mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 20:08:42 +00:00
Merge pull request #6888 from dotCooCoo/fix/read-roots-scripts-recovery
MCP: plug-in, recovery and script report replies leave out paths outside the read roots
This commit is contained in:
@@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under automation read roots, a reply names a file or folder outside them by its name alone.
|
||||
fn shown_file(path: &str) -> String {
|
||||
super::fileio::shown_path(path).into_owned()
|
||||
}
|
||||
|
||||
/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a
|
||||
/// file outside the automation read roots is named by its file name.
|
||||
fn shown_item(item: &safe_mode::Item) -> Value {
|
||||
let mut v = item.to_json();
|
||||
if let Some(file) = item.file.as_deref() {
|
||||
v["file"] = json!(shown_file(file));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// JSON description of an installed plug-in.
|
||||
pub fn describe(p: &Plugin) -> Value {
|
||||
let m = p.manifest();
|
||||
let mut v = json!({
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
"size": p.size(), "source": p.source().map(shown_file),
|
||||
"permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api,
|
||||
});
|
||||
match m.kind {
|
||||
@@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them
|
||||
/// is `null` and a failed file outside them is named by its file name, in the errors too.
|
||||
fn shown_report(mut report: Value) -> Value {
|
||||
let mut hidden: Vec<(String, String)> = vec![];
|
||||
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
|
||||
for row in failed {
|
||||
let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue };
|
||||
let shown = shown_file(&file);
|
||||
if shown != file {
|
||||
row["file"] = json!(shown);
|
||||
hidden.push((file, shown));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) {
|
||||
let shown = shown_file(&folder);
|
||||
if shown != folder {
|
||||
report["folder"] = Value::Null;
|
||||
hidden.push((folder, shown));
|
||||
}
|
||||
}
|
||||
// A failed file's path holds its folder's: the longer paths are put back first.
|
||||
hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));
|
||||
let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name));
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) {
|
||||
report["error"] = json!(e);
|
||||
}
|
||||
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
|
||||
for row in failed {
|
||||
if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) {
|
||||
row["error"] = json!(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect();
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone()));
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(shown_item).collect();
|
||||
Ok(
|
||||
json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
|
||||
)
|
||||
@@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"active": safe_mode::active(),
|
||||
"source": safe_mode::source().map(safe_mode::Source::name),
|
||||
"disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>(),
|
||||
"pendingItem": safe_mode::previous().map(|i| i.to_json()),
|
||||
"disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>(),
|
||||
"pendingItem": safe_mode::previous().map(|i| shown_item(&i)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name
|
||||
/// by its file name (outside the automation read roots) is found by that name, when no other listed
|
||||
/// file has it.
|
||||
fn listed_file(file: String) -> String {
|
||||
let mut listed: Vec<String> = safe_mode::disabled()
|
||||
.into_iter()
|
||||
.chain(safe_mode::previous())
|
||||
.filter_map(|i| i.file)
|
||||
.chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string)))
|
||||
.collect();
|
||||
listed.sort();
|
||||
listed.dedup();
|
||||
if listed.contains(&file) {
|
||||
return file;
|
||||
}
|
||||
let mut named = listed.into_iter().filter(|f| shown_file(f) == file);
|
||||
match (named.next(), named.next()) {
|
||||
(Some(f), None) => f,
|
||||
_ => file,
|
||||
}
|
||||
}
|
||||
|
||||
/// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a
|
||||
/// plug-in that stopped a launch while loading may be known by its file alone). It neither loads
|
||||
/// nor unloads anything now, and uninstalls nothing.
|
||||
@@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None };
|
||||
(registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored)
|
||||
}
|
||||
(None, Some(file)) => (safe_mode::Item::file(file), None),
|
||||
(None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None),
|
||||
(None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")),
|
||||
};
|
||||
let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect();
|
||||
@@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without a plug-in folder (a session confined to automation roots), or when the list there
|
||||
// can't take the change, the change stays in this process and goes with the next change that saves.
|
||||
let saved = !changed || s.store_disabled_plugins();
|
||||
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>()}))
|
||||
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1055,7 +1129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -1127,7 +1201,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Safe Mode",
|
||||
[],
|
||||
None,
|
||||
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
|
||||
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
|
||||
always,
|
||||
safe_mode_state
|
||||
),
|
||||
@@ -1136,7 +1210,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Enable on Re-launch",
|
||||
[],
|
||||
None,
|
||||
"{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
|
||||
"{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
|
||||
always,
|
||||
set_enabled
|
||||
),
|
||||
|
||||
@@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync {
|
||||
fn remove(&self, name: &str) -> std::result::Result<(), String>;
|
||||
/// Where the entries are, as the user knows it (a folder, "browser storage").
|
||||
fn location(&self) -> String;
|
||||
/// Is [`RecoveryStore::location`] a folder on this computer (default: no)?
|
||||
fn in_folder(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The areas in the store (default: those with entries).
|
||||
fn areas(&self) -> std::result::Result<Vec<String>, String> {
|
||||
let names = self.list()?;
|
||||
@@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore {
|
||||
fn location(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn in_folder(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn areas(&self) -> std::result::Result<Vec<String>, String> {
|
||||
Self::names(&self.0, true)
|
||||
}
|
||||
@@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's file `path` as a reply names it: under automation read roots, only inside them.
|
||||
fn shown_path(path: Option<String>) -> Option<String> {
|
||||
path.filter(|p| crate::file_access::check_read(p).is_ok())
|
||||
}
|
||||
|
||||
/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation
|
||||
/// read roots is `null`.
|
||||
fn shown_location(store: &dyn RecoveryStore) -> Value {
|
||||
let location = store.location();
|
||||
if store.in_folder() && crate::file_access::check_read(&location).is_err() {
|
||||
return Value::Null;
|
||||
}
|
||||
json!(location)
|
||||
}
|
||||
|
||||
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
|
||||
store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
forget_clean(s);
|
||||
heartbeat(s);
|
||||
Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() }))
|
||||
Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) }))
|
||||
}
|
||||
|
||||
/// Who an area of the store belongs to.
|
||||
@@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let meta = read_meta(store.as_ref(), f);
|
||||
let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title };
|
||||
rows.push(json!({
|
||||
"file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved,
|
||||
"file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved,
|
||||
"open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running,
|
||||
}));
|
||||
}
|
||||
@@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
tidy(store.as_ref(), &area, hold);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "copies": rows, "location": store.location() }))
|
||||
Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) }))
|
||||
}
|
||||
|
||||
/// A gone app's area to act on: its name, the copies to act on, and its lock.
|
||||
@@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
// The copy stays until the document is saved or closed.
|
||||
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
|
||||
let title = st.title();
|
||||
Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json()))
|
||||
Ok(super::fileio::merge(
|
||||
json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }),
|
||||
links.to_json(),
|
||||
))
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1527,7 +1552,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Recovery Data",
|
||||
[],
|
||||
None,
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)",
|
||||
has_store,
|
||||
save
|
||||
),
|
||||
@@ -1536,7 +1561,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Recovery Data",
|
||||
[],
|
||||
None,
|
||||
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash",
|
||||
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is",
|
||||
has_store,
|
||||
list
|
||||
),
|
||||
@@ -1545,7 +1570,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Restore Recovered Documents",
|
||||
[],
|
||||
None,
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
has_store,
|
||||
restore
|
||||
),
|
||||
|
||||
@@ -233,6 +233,43 @@ impl Session {
|
||||
self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
/// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read
|
||||
/// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in
|
||||
/// (`file`, also in the run's error line in `console`) are named by their file name. What the
|
||||
/// script wrote itself (`log`, its output lines) stays as written.
|
||||
pub fn shown_script_report(&self) -> Option<Value> {
|
||||
let mut report = self.script_report()?.clone();
|
||||
if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) {
|
||||
for path in written.iter_mut() {
|
||||
if let Some(name) = path.as_str().and_then(hidden_name) {
|
||||
*path = json!(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string)
|
||||
&& let Some(name) = hidden_name(&file)
|
||||
{
|
||||
// The run's error lines come after every line the script wrote. They are written again
|
||||
// from the report with the file's name, which also covers a line the console cut inside
|
||||
// the path. A console that dropped its oldest lines may hold only the last of them.
|
||||
let before = script::error_lines(&report);
|
||||
report["file"] = json!(name);
|
||||
if let Some(message) = report.get("message").and_then(Value::as_str) {
|
||||
report["message"] = json!(message.replace(&file, &name));
|
||||
}
|
||||
let after = script::error_lines(&report);
|
||||
if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) {
|
||||
let kept = (1..=before.len().min(lines.len()))
|
||||
.rev()
|
||||
.find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str()))))
|
||||
.unwrap_or(0);
|
||||
lines.truncate(lines.len() - kept);
|
||||
lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l)));
|
||||
}
|
||||
}
|
||||
Some(report)
|
||||
}
|
||||
|
||||
/// The text of the script at `path`, which a listing of the Scripts folder gave
|
||||
/// ([`script::folder::read`]), checked as `script.run` checks a file's.
|
||||
pub fn installed_script(&self, path: &str) -> Result<String> {
|
||||
@@ -248,6 +285,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name a reply names `path` by when the file is outside the automation read roots; `None`
|
||||
/// when a reply may name it whole.
|
||||
fn hidden_name(path: &str) -> Option<String> {
|
||||
let shown = super::fileio::shown_path(path);
|
||||
(shown != path).then(|| shown.into_owned())
|
||||
}
|
||||
|
||||
fn list_installed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(root) = s.scripts_dir() else {
|
||||
return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}));
|
||||
|
||||
@@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
|
||||
host.console.borrow_mut().push(line, cut);
|
||||
}
|
||||
|
||||
/// The error of the failed run `report` describes, as its console lines give it; `name` is the
|
||||
/// run's name in those lines ([`console::line_name`]).
|
||||
fn error_text(name: &str, report: &Value) -> String {
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let message = report["message"].as_str().unwrap_or_default();
|
||||
match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"),
|
||||
_ if line > 0 => format!("{name}:{line}:{column}: {message}"),
|
||||
_ => format!("{name}: location unavailable: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The console lines of the error of the failed run `report` describes, each as the console keeps
|
||||
/// it, cut to its limit.
|
||||
pub(crate) fn error_lines(report: &Value) -> Vec<String> {
|
||||
let name = console::line_name(report["name"].as_str().unwrap_or_default());
|
||||
let mut lines = vec![];
|
||||
each_line(&error_text(&name, report), |line, _| lines.push(line));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
|
||||
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
|
||||
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
|
||||
@@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
|
||||
}
|
||||
}
|
||||
if report["ok"] == false {
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let message = report["message"].as_str().unwrap_or_default();
|
||||
let text = match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name),
|
||||
_ if line > 0 => format!("{}:{line}:{column}: {message}", host.name),
|
||||
_ => format!("{}: location unavailable: {message}", host.name),
|
||||
};
|
||||
each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut));
|
||||
each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut));
|
||||
}
|
||||
let timing = clock().is_some();
|
||||
let ms = millis(host.run_nanos.get());
|
||||
|
||||
@@ -1433,6 +1433,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() {
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed
|
||||
/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file
|
||||
/// outside is named by its file name.
|
||||
#[test]
|
||||
fn plugin_list_leaves_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let base = crate::tests_links::Folder::new("plugin-list-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let id = "test.engine.listroots";
|
||||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||||
std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||||
std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap();
|
||||
let folder = outside.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
let r = run(&mut s, "plugin.reload", json!({"path": folder}));
|
||||
assert_eq!(r["loaded"], json!([id]), "{r}");
|
||||
let broken = outside.join("broken.wasm").to_string_lossy().to_string();
|
||||
let source = outside.join("hidden.wasm").to_string_lossy().to_string();
|
||||
let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap();
|
||||
|
||||
// The person sees the paths.
|
||||
let person = run(&mut s, "plugin.list", json!({}));
|
||||
assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}");
|
||||
assert_eq!(entry(&person)["source"], json!(source));
|
||||
|
||||
// The client sees their names.
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
let report = &listed["folder"];
|
||||
assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}");
|
||||
assert_eq!(entry(&listed)["source"], "hidden.wasm");
|
||||
let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap();
|
||||
assert_eq!(info["source"], "hidden.wasm");
|
||||
let outside_text = json!(folder).to_string();
|
||||
for reply in [&listed, &info] {
|
||||
assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}");
|
||||
}
|
||||
|
||||
// A read root holding the folder shows it whole.
|
||||
let wider = AutomationRoots::new(&[&outside], &[]).unwrap();
|
||||
let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// Under automation read roots, a plug-in turned off for the next launch is listed with a file
|
||||
/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name.
|
||||
#[test]
|
||||
fn disabled_plugins_name_files_outside_the_read_root_by_their_names() {
|
||||
if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") {
|
||||
return;
|
||||
}
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let base = crate::tests_links::Folder::new("plugin-disabled-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let id = "test.engine.disabledroots";
|
||||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||||
let file = outside.join("off.wasm");
|
||||
std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap();
|
||||
let file = file.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
run(&mut s, "plugin.install", json!({"path": file}));
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
// The client turns it off by the source plugin.list shows.
|
||||
let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone();
|
||||
assert_eq!(source, "off.wasm", "{listed}");
|
||||
confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap();
|
||||
assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path");
|
||||
|
||||
let outside_text = json!(outside.to_string_lossy()).to_string();
|
||||
for cmd in ["plugin.list", "plugin.safeMode"] {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap();
|
||||
assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}");
|
||||
assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}");
|
||||
}
|
||||
let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap();
|
||||
assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// #2541: a plug-in installed in a session with a plug-in store is installed again in the next
|
||||
/// session that attaches the store, and Remove Plug-in takes it out of the store.
|
||||
#[test]
|
||||
|
||||
@@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and
|
||||
/// `file.recovery.restore` name a document's file and the recovery folder only inside them.
|
||||
#[test]
|
||||
fn recovery_replies_leave_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
use serde_json::Value;
|
||||
let base = crate::tests_links::Folder::new("recovery-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let store: Arc<dyn RecoveryStore> = Arc::new(FolderStore::new(outside.join("recovery")));
|
||||
let location = outside.join("recovery").to_string_lossy().to_string();
|
||||
let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string();
|
||||
let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.recovery.set_store(store.clone());
|
||||
for path in [&kept, &hidden] {
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
}
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap();
|
||||
assert_eq!(saved["location"], Value::Null, "{saved}");
|
||||
s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap();
|
||||
assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder");
|
||||
|
||||
let sorted = |v: &Value, key: &str| -> Vec<String> {
|
||||
let mut out: Vec<String> = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect();
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
let both = |first: Value| {
|
||||
let mut out = vec![first.to_string(), json!(kept).to_string()];
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden)));
|
||||
let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}");
|
||||
let outside_text = json!(outside.to_string_lossy()).to_string();
|
||||
assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}");
|
||||
// A relative recovery folder is checked as the file system reads it; a store that is no folder
|
||||
// keeps its name.
|
||||
let mut relative = Session::new();
|
||||
relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder")));
|
||||
let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!(l["location"], Value::Null, "{l}");
|
||||
let (mut memory, _) = session();
|
||||
let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!(l["location"], "memory", "{l}");
|
||||
|
||||
// After a crash, the copies restored.
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.recovery.set_store(store.clone());
|
||||
let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap();
|
||||
assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}");
|
||||
let mut opened: Vec<Option<String>> = s.documents().iter().map(|d| d.path.clone()).collect();
|
||||
opened.sort();
|
||||
let mut files = vec![Some(hidden), Some(kept)];
|
||||
files.sort();
|
||||
assert_eq!(opened, files, "the documents keep their files");
|
||||
}
|
||||
|
||||
/// A crash between writing a copy's document and its details restores the new document with
|
||||
/// every image, the one placed since the last copy too (and the first copy likewise).
|
||||
#[test]
|
||||
|
||||
@@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() {
|
||||
assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]);
|
||||
assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4));
|
||||
}
|
||||
|
||||
/// Under automation read roots, the error lines of a failed run name the included file it stopped
|
||||
/// in by its file name also when the console cut a line inside the file's path.
|
||||
#[test]
|
||||
fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let base = crate::tests_links::Folder::new("script-report-cut-line");
|
||||
let inside = base.0.join("in");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
// A path longer than a console line, as the report of a failed run names it.
|
||||
let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string();
|
||||
let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")});
|
||||
let error = script::error_lines(&report);
|
||||
assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}");
|
||||
let written: Vec<String> = std::iter::once("written by the script".to_string()).chain(error).collect();
|
||||
report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2});
|
||||
let mut s = Session::new();
|
||||
s.script_report = Some(report.clone());
|
||||
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap();
|
||||
assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}");
|
||||
assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}");
|
||||
let outside = json!(base.0.join("out").to_string_lossy()).to_string();
|
||||
assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}");
|
||||
// The person sees the report as it is.
|
||||
assert_eq!(s.shown_script_report(), Some(report));
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
}
|
||||
let mut inspected = json!({
|
||||
"scriptRunning": app.session.script_running(),
|
||||
"scriptReport": app.session.script_report(),
|
||||
"scriptReport": app.session.shown_script_report(),
|
||||
"tool": app.session.tool_id(),
|
||||
"toolOptions": app.session.tool_options(),
|
||||
"ui": ui,
|
||||
|
||||
@@ -567,6 +567,50 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect`'s `scriptReport` names a file the person's script wrote outside the read root, and
|
||||
/// the included file a failed run stopped in (also in its error line), by its file name; the person
|
||||
/// sees the paths.
|
||||
#[test]
|
||||
fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() {
|
||||
let (mut app, tx, base) = confined("inspect-script-report");
|
||||
let (kept, hidden) = (s(base.join("in/kept.svg")), s(base.join("outside/hidden.svg")));
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let source = format!(
|
||||
"vc.run('document.export', {{path: {kept}, format: 'svg'}}); vc.run('document.export', {{path: {hidden}, format: 'svg'}});",
|
||||
kept = json!(kept),
|
||||
hidden = json!(hidden)
|
||||
);
|
||||
let finish = |app: &mut VectorcraftApp, params: Value| {
|
||||
app.session.execute("script.run", ¶ms).unwrap();
|
||||
for _ in 0..10_000 {
|
||||
if app.session.script_pump().is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
finish(&mut app, json!({"source": source, "name": "t.js"}));
|
||||
let ctx = egui::Context::default();
|
||||
let outside = json!(s(base.join("outside"))).to_string();
|
||||
let person = crate::control::inspect(&app, &ctx);
|
||||
assert_eq!(person["scriptReport"]["written"], json!([kept, hidden]), "{person}");
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["scriptReport"]["written"], json!([kept, "hidden.svg"]), "{r}");
|
||||
assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
|
||||
assert_eq!(app.session.script_report().unwrap()["written"], json!([kept, hidden]), "the report is not changed");
|
||||
|
||||
// A run that fails in a file it includes.
|
||||
std::fs::write(base.join("outside/run.jsx"), "#include \"bad.jsxinc\"\n").unwrap();
|
||||
std::fs::write(base.join("outside/bad.jsxinc"), "\nthrow new Error('stopped here');\n").unwrap();
|
||||
finish(&mut app, json!({"path": s(base.join("outside/run.jsx"))}));
|
||||
let person = crate::control::inspect(&app, &ctx);
|
||||
assert_eq!(person["scriptReport"]["file"], json!(s(base.join("outside/bad.jsxinc"))), "{person}");
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["scriptReport"]["file"], "bad.jsxinc", "{r}");
|
||||
assert!(r["result"]["scriptReport"]["console"].to_string().contains("bad.jsxinc:2:"), "{r}");
|
||||
assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A client without automation roots sees every loaded library's full id in `ui.inspect`.
|
||||
#[test]
|
||||
fn ui_inspect_shows_full_library_ids_without_a_read_root() {
|
||||
|
||||
+11
@@ -180,6 +180,17 @@ the suite) keep the agent to the folders you give it:
|
||||
`null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and
|
||||
`prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the
|
||||
read roots, and the preset stays. The preferences themselves are not changed.
|
||||
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
|
||||
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
|
||||
folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source`
|
||||
(also in `plugin.info`) are named by their file name, in the errors too. A plug-in turned off for the next
|
||||
launch (`disabled`, and `pendingItem` in `plugin.safeMode`) has a file outside the read roots named by its file
|
||||
name. `plugin.setEnabled {file}` takes that name, and a plug-in's `source` name, when no other listed file has
|
||||
it. In `file.recovery.list` and `file.recovery.restore`, a
|
||||
document's original file in `path` is `null`, and so is the recovery folder in `location` (`file.recovery.save`
|
||||
too); the restored documents keep their files. In `scriptReport`, a file the run wrote (`written`) and the
|
||||
included file a failed run stopped in (`file`, also in `message` and the run's error lines in `console`) are named
|
||||
by their file name; what the script wrote itself (`log` and its output lines) stays as written.
|
||||
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
|
||||
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
|
||||
output folder and a scratch folder, without widening a root to their common parent. A folder given twice
|
||||
|
||||
@@ -771,6 +771,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`.
|
||||
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
|
||||
- Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`.
|
||||
- Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`).
|
||||
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.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 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. 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).
|
||||
|
||||
Reference in New Issue
Block a user