From e6f330afd23abc78514265cd078ab1cd384bb803 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:59:18 -0800 Subject: [PATCH] Automation: ui.dialog.set cannot change the command a dialog runs ui.dialog.set took every field whose name starts with `__`, so a control request (or MCP's answer_dialog, which goes through it) could rewrite the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command`, or the paste a swatch conflict waits to finish, after a person opened the dialog. The person's OK then ran something else. A `__` field is now settable only when it is a control of that kind of dialog: a section or tab shown, a link between two fields, a name or search being typed (for example Save PDF's `__section`, Transform Each's `__constrain`, the Preferences `__search`). Every other `__` field, held or not, is refused with "the `` dialog cannot set protected field ``". The request sets no field, and with `action: "confirm"` OK does not run. Flags a button sets together with other fields are refused too: the style dialogs' `__reset*`, Artboard Options' `__fit`, `__sizeChoice`, `__refx` and `__refy`, and Scribble's `__scribbleSetting`. Recolor's Merge takes a row listed twice in the selection once, and Randomize keeps a seed at u64::MAX instead of overflowing it. Docs: docs/control-protocol.md, docs/mcp.md, docs/target-app-parity.md. --- crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/control/tests.rs | 8 +- crates/ui-egui/src/dialogs.rs | 69 +++++- .../src/dialogs/brush_options/tests.rs | 10 +- crates/ui-egui/src/dialogs/recolor.rs | 27 ++- crates/ui-egui/src/placing/tests.rs | 3 +- crates/ui-egui/src/tests/automation.rs | 204 +++++++++++++++++- docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- docs/target-app-parity.md | 1 + 10 files changed, 312 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..1c09f918f9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,6 +18,8 @@ //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` or `{fields: [{field, value}…]}` (all or none; setting a field //! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]; +//! a `__` field other than the controls of the dialog's kind is an error too, so a request cannot +//! change the command, targets or file the dialog's OK acts on: [`crate::dialogs::protected_field`]; //! `action: confirm | cancel` then confirms or cancels that dialog in the same request and replies //! `{dialog, result}`) / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG @@ -603,6 +605,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques }; // Every field is checked before any is set, so a request sets all of them or none. if let Some((f, _)) = fields.iter().find(|(f, _)| !crate::dialogs::accepts_field(d, f)) { + if crate::dialogs::protected_field(d, f) { + return err(format!("the `{}` dialog cannot set protected field `{f}`", d.kind)); + } let names = crate::dialogs::field_names(d).join(", "); return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1763fd679a..d441946622 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -767,7 +767,8 @@ fn save_and_close_apply_a_merge_on_another_thread_first() { /// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields, /// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard` -/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`. +/// with a discard button, `preview` with a live preview and the spec's `inputs`. A `__` field that +/// is not one of the controls of the dialog's kind is refused. #[test] fn dialog_set_refuses_a_field_the_dialog_does_not_have() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -776,8 +777,11 @@ fn dialog_set_refuses_a_field_the_dialog_does_not_have() { let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true})); assert_eq!(r["ok"], false, "{r}"); assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid"); + assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields.clone())); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "__note", "value": true})); + assert_eq!(r["error"], "the `saveChanges` dialog cannot set protected field `__note`", "{r}"); assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields)); - for field in ["name", "discard", "__note"] { + for field in ["name", "discard"] { let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); assert_eq!(r["ok"], true, "{field}: {r}"); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..9274bbd40c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -481,13 +481,69 @@ fn spec(kind: &str) -> &'static DialogSpec { DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec) } -/// Whether `ui.dialog.set` can set `field` on `d`: a field it holds, a `__` field (the dialog's -/// own state), `discard` on a dialog with a discard button, `preview` on one with a live preview, -/// one of its spec's `inputs` for its kind, or one it `passes` on. +/// The `__` fields that `ui.dialog.set` can set on `d`: controls of its kind that the dialog holds +/// under a `__` name, each set as the control sets it. Every other `__` field of a dialog holds +/// what its OK acts on (the command and its fixed params, the targets, a file, the preset being +/// edited) or state the dialog keeps for itself, and `ui.dialog.set` refuses it +/// ([`protected_field`]), so a request cannot change what OK does after a person opened the dialog. +/// Flags that a button sets together with other fields are refused too: the style dialogs' +/// `__reset*` (Reset Panel also clears the section's fields, so scripts run +/// `ui.charStyleOptions.resetPanel` or `ui.paraStyleOptions.resetPanel`) and Artboard Options' +/// `__fit`, `__sizeChoice`, `__refx` and `__refy` (set with the size and position they record), and +/// Scribble's `__scribbleSetting` (the menu sets the preset's parameters with it). +fn hidden_controls(d: &Dialog) -> &'static [&'static str] { + match DialogKind::of(&d.kind) { + // Duotone's tone row, and the 3D effects' tab, gizmo, light and Show axis. + Some(DialogKind::Effect) => &["__gizmo", "__light", "__showAxis", "__tab", "__tone"], + // The preset name being typed and the format whose settings the gear shows. + Some(DialogKind::ExportForScreens) => &["__savePreset", "__settings"], + // Keywords typed but not added yet. + Some(DialogKind::FileInfo) => &["__keyword"], + // The preset name being typed. + Some(DialogKind::FlattenTransparency) => &["__presetName"], + // The Resolution menu's Other. + Some(DialogKind::RasterOptions | DialogKind::RasterFormatOptions | DialogKind::PsdOptions) => &["__otherPpi"], + // The category shown, the search text and the three settings kept outside the preferences. + Some(DialogKind::Preferences) => &["__category", "__search", "__smartGuides", "__snapToGrid", "__taskBar"], + // The section shown, the sheet the preview shows and the preset name being typed. + Some(DialogKind::Print | DialogKind::PrintPreset) => &["__savePresetAs", "__section", "__sheet"], + // The section shown, All or a range of artboards, the two password checkboxes and the + // preset name being typed. + Some(DialogKind::SavePdf | DialogKind::PdfPreset) => { + &["__allArtboards", "__requireOpenPassword", "__restrictPermissions", "__savePresetAs", "__section"] + } + // The selected rows and Randomize's seed. + Some(DialogKind::Recolor) => &["__seed", "__sel"], + // The view, zoom, download speed, selected color table entry and the preset name being typed. + Some(DialogKind::SaveForWeb) => &["__color", "__kbps", "__presetName", "__view", "__zoom"], + // The link that keeps width and height in proportion. + Some(DialogKind::Shape | DialogKind::TransformEach) => &["__constrain"], + // The attention filter, the Keys section, the search text and the key being recorded. + Some(DialogKind::ShortcutImport) => &["__attention", "__keysOpen", "__query", "__recording"], + // Set Size of Each's Width and Height checkboxes. + Some(DialogKind::ArrangeTools) if d.kind == crate::addons::arrange::SIZE => &["__height", "__width"], + // The search field. + Some(DialogKind::SelectByArtboards | DialogKind::SelectByLinkStatus) => &["__search"], + _ => &[], + } +} + +/// Whether `field` is a `__` field of `d` that `ui.dialog.set` refuses: any `__` field, held or +/// not, except the controls of `d`'s kind ([`hidden_controls`]). +pub fn protected_field(d: &Dialog, field: &str) -> bool { + field.starts_with("__") && !hidden_controls(d).contains(&field) +} + +/// Whether `ui.dialog.set` can set `field` on `d`: a `__` field only when it is one of the controls +/// of `d`'s kind ([`protected_field`]); otherwise a field it holds, `discard` on a dialog with a +/// discard button, `preview` on one with a live preview, one of its spec's `inputs` for its kind, +/// or one it `passes` on. pub fn accepts_field(d: &Dialog, field: &str) -> bool { + if field.starts_with("__") { + return !protected_field(d, field); + } let spec = spec(&d.kind); d.fields.contains_key(field) - || field.starts_with("__") || (field == "discard" && spec.discard.is_some()) || (field == "preview" && spec.preview) || (spec.inputs)(&d.kind).contains(&field) @@ -520,11 +576,12 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { } } -/// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, -/// `discard`, `preview` and the spec's `inputs` for its kind. +/// The fields `ui.dialog.set` can set on `d`, sorted: those it holds other than its `__` ones, the +/// `__` controls of its kind, `discard`, `preview` and the spec's `inputs` for its kind. pub fn field_names(d: &Dialog) -> Vec { let spec = spec(&d.kind); let mut names: Vec = d.fields.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + names.extend(hidden_controls(d).iter().map(|f| f.to_string())); names.extend(spec.discard.map(|_| "discard".to_string())); names.extend(spec.preview.then(|| "preview".to_string())); names.extend((spec.inputs)(&d.kind).iter().map(|i| i.to_string())); diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 3ae028bb05..f2fcc88981 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -436,13 +436,17 @@ fn each_tips_sample_paints_the_brush_with_its_method() { assert!(tints != hue && tints != shades && none != shades, "four different samples"); } -/// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not -/// an object, Brush Options and Colorization Tips still draw. +/// With a brush definition that is not an object, Brush Options and Colorization Tips still draw. +/// `ui.dialog.set` refuses `__def`, which holds the brush OK saves. #[test] fn a_brush_definition_that_is_not_an_object_still_draws() { let (mut app, ctx) = (app(), ctx()); app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); - crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5})); + let (req, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__def", "value": 5})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(r["error"], "the `brushOptions` dialog cannot set protected field `__def`", "{r}"); + assert_ne!(app.ui.dialog.as_ref().unwrap().fields.get("__def"), Some(&json!(5)), "the request is refused"); + set(&mut app, json!({"__def": 5})); crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true})); let text = shown(&mut app); assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}"); diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 6f696f4302..784a19007b 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -262,6 +262,11 @@ fn excluded(row: &Value) -> bool { row["exclude"].as_bool().unwrap_or(false) } +/// The seed for the next Randomize: one past the last, held at `u64::MAX`. +fn next_seed(d: &Dialog) -> u64 { + (d.f64("__seed", 0.0) as u64).saturating_add(1) +} + fn selection(d: &Dialog) -> Vec { d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default() } @@ -546,7 +551,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [("arrow-left-right", tl!("Randomly change color order"), false), ("sparkles", tl!("Randomly change saturation and brightness"), true)] { if widgets::icon_button_enabled(ui, icon, tip, false, rows.len() > 1 || sb, 24.0).clicked() { - let seed = d.f64("__seed", 0.0) as u64 + 1; + let seed = next_seed(d); d.fields.insert("__seed".into(), json!(seed)); let p = json!({"map": rows, "order": !sb, "saturationBrightness": sb, "seed": seed}); match app.session.execute("recolor.randomize", &p) { @@ -897,6 +902,7 @@ fn sliders(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value] fn merge(rows: &mut Vec, sel: &[usize]) { let mut sel: Vec = sel.iter().copied().filter(|i| *i < rows.len()).collect(); sel.sort_unstable(); + sel.dedup(); let Some((&first, rest)) = sel.split_first() else { return }; let mut moved = vec![]; for &i in rest.iter().rev() { @@ -1208,6 +1214,25 @@ mod tests { assert_eq!(r[2]["to"], "rgb 0 0 128"); } + /// A selection that lists a row twice merges it once, as `ui.dialog.set` can store one. + #[test] + fn merge_takes_a_repeated_row_once() { + let mut r = rows(); + merge(&mut r, &[0, 2, 2]); + assert_eq!(r.len(), 2); + assert_eq!(r[0]["from"], json!(["rgb 255 0 0", "rgb 0 255 0"])); + merge(&mut r, &[1, 1]); + assert_eq!(r.len(), 2); + } + + /// Randomize after a seed past `u64::MAX` keeps the largest seed. + #[test] + fn next_seed_holds_at_the_largest() { + assert_eq!(next_seed(&Dialog::new("recolor", json!({}))), 1); + assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 4}))), 5); + assert_eq!(next_seed(&Dialog::new("recolor", json!({"__seed": 1e30}))), u64::MAX); + } + #[test] fn linked_handles_move_together() { let mut r = rows(); diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs index 96492a6512..9afe94b9ee 100644 --- a/crates/ui-egui/src/placing/tests.rs +++ b/crates/ui-egui/src/placing/tests.rs @@ -587,7 +587,8 @@ fn a_deferred_swatch_paste_rejects_a_changed_token_or_closed_document() { &crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__deferredPaste", "value": 999999})).0, ); let crate::control::Outcome::Done(result) = result else { panic!("expected dialog result") }; - assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["error"], "the `swatchConflict` dialog cannot set protected field `__deferredPaste`", "{result}"); + app.ui.dialog.as_mut().unwrap().fields.insert("__deferredPaste".into(), json!(999999)); } let error = crate::dialogs::confirm(&mut app).unwrap_err(); assert!( diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index df2fe29912..d2d0a8c4b6 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -10,6 +10,7 @@ use vectorcraft_engine::file_access::AutomationRoots; use crate::background::SharedWriteFn; use crate::control::ControlRequest; +use crate::state::Dialog; use crate::{Services, VectorcraftApp}; /// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside` @@ -658,12 +659,12 @@ fn automation_cannot_approve_a_plugin_install() { assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); assert!(vectorcraft_plugins::registry::get(id).is_none()); - // What the dialog installs isn't in its fields. + // What the dialog installs isn't in its fields, and its `__` fields can't be set. let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); // What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`. let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); refused(&r, ASKED); assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); @@ -691,6 +692,200 @@ fn automation_cannot_approve_a_plugin_install() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// An app with a control channel and a new document. +fn controlled() -> (VectorcraftApp, std::sync::mpsc::Sender) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + (app, tx) +} + +/// The fields of the open dialog. +fn dialog_fields(app: &VectorcraftApp) -> Option> { + app.ui.dialog.as_ref().map(|d| d.fields.clone()) +} + +/// The objects on the document's first layer. +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) +} + +/// The left edge and width of the first object on the document's first layer. +fn first_object(app: &VectorcraftApp) -> (f64, f64) { + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + (b.x0, b.width()) +} + +/// Each of `sets` sent as `ui.dialog.set` is refused with the protected-field error of the +/// `kind` dialog, sets no field, and runs nothing. +fn refused_sets(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender, kind: &str, sets: &[Value]) { + for set in sets { + let before = dialog_fields(app); + let r = request(app, tx, "ui.dialog.set", set.clone()); + let says = format!("the `{kind}` dialog cannot set protected field `__"); + assert!(r["error"].as_str().is_some_and(|e| e.starts_with(&says)), "{set}: {r}"); + assert_eq!(dialog_fields(app), before, "{set}: no field changes and the dialog stays open"); + assert_eq!(objects(app), 0, "{set}: nothing ran"); + } +} + +/// A confirmation a person opened runs the command it was opened with: `ui.dialog.set` can't +/// change its `__command` or `__params`, alone or in a list with other fields and `action: +/// "confirm"`, and OK then runs the command it asked about. +#[test] +fn dialog_set_cannot_redirect_person_opened_confirm() { + let (mut app, tx) = controlled(); + let rect = json!({"x": 10, "y": 10, "width": 20, "height": 20}); + crate::dialogs::confirm::ask(&mut app, "Add a rectangle?", "", "shape.rectangle", rect); + let other = json!({"x": 0, "y": 0, "width": 90, "height": 90}); + refused_sets( + &mut app, + &tx, + crate::dialogs::confirm::KIND, + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": other}), + json!({"fields": [{"field": "detail", "value": "Sure?"}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + json!({"fields": [{"field": "__params", "value": other}, {"field": "detail", "value": "Sure?"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "detail", "value": "Sure?"}], "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the command it asked about"); + assert_eq!(first_object(&app), (10.0, 20.0)); +} + +/// The generic parameter dialog runs its command with its fixed parameters: `ui.dialog.set` can +/// change the parameters it shows, not `__command`, `__params` or `__label`. +#[test] +fn dialog_set_cannot_redirect_parameter_dialog() { + let (mut app, tx) = controlled(); + let fields = json!({"__command": "shape.rectangle", "__label": "Rectangle", "__params": {"x": 10, "y": 10}, "width": 20, "height": 20}); + app.ui.dialog = Some(Dialog::new("command", fields)); + refused_sets( + &mut app, + &tx, + "command", + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": {"x": 500, "y": 500}}), + json!({"field": "__label", "value": "Close"}), + json!({"fields": [{"field": "width", "value": 30}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "width", "value": 30, "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the dialog's command"); + assert_eq!(first_object(&app), (10.0, 30.0), "with its fixed x and the width set"); +} + +/// A plug-in command's dialog runs that command of that plug-in: `ui.dialog.set` can't change its +/// `__command` or `__plugin`. +#[test] +fn dialog_set_cannot_redirect_plugin_command() { + let (mut app, tx) = controlled(); + let fields = json!({"__plugin": "test.ui.redirect", "__command": "plugin.test.ui.redirect.a", "__label": "A", "amount": 1}); + app.ui.dialog = Some(Dialog::new(crate::dialogs::plugin::KIND, fields)); + refused_sets( + &mut app, + &tx, + crate::dialogs::plugin::KIND, + &[ + json!({"field": "__command", "value": "plugin.test.ui.redirect.b"}), + json!({"field": "__plugin", "value": "test.ui.other"}), + json!({"fields": [{"field": "amount", "value": 2}, {"field": "__command", "value": ""}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "amount", "value": 2})); + assert_eq!(r["ok"], true, "{r}"); +} + +/// A `__` field the dialog does not hold is refused as a protected field too, and so is a control +/// of another kind of dialog; the error for an unknown field lists the dialog's own controls only. +#[test] +fn dialog_set_rejects_unknown_hidden_field() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + for field in ["__anything", "__section", "__ratio", "__constrainApplied"] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["error"], format!("the `transformEach` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key("__anything")); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true})); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains("has no field `nosuch`") && error.contains("__constrain"), "{r}"); + assert!(!error.contains("__ratio") && !error.contains("__section"), "{r}"); + + // Artboard Options' Fit, size preset and reference point flags are set by their buttons + // together with the size and position they record. + app.ui.dialog = Some(Dialog::new("artboardOptions", json!({"width": 60, "height": 20}))); + for (field, value) in [("__fit", json!(true)), ("__sizeChoice", json!({"landscape": true})), ("__refx", json!(2)), ("__refy", json!(2))] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(r["error"], format!("the `artboardOptions` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key(field)); + } + // Scribble's Settings menu sets the preset's parameters along with its name. + app.ui.dialog = Some(Dialog::new("effect", json!({"__effect": "scribble", "angle": 30}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__scribbleSetting", "value": "sharp"})); + assert_eq!(r["error"], "the `effect` dialog cannot set protected field `__scribbleSetting`", "{r}"); +} + +/// The dialog controls held under a `__` name are still set: Transform Each's link, the Save PDF +/// section and its All artboards checkbox, and the Preferences search. +#[test] +fn dialog_set_allows_hidden_controls() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__constrain", "value": true})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("__constrain"))); + app.ui.dialog = None; + + app.run("ui.savePdfDialog", json!({})).unwrap(); + let set = json!({"fields": [{"field": "__section", "value": "Security"}, {"field": "__allArtboards", "value": false}]}); + let r = request(&mut app, &tx, "ui.dialog.set", set); + assert_eq!(r["ok"], true, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__section"), d.bool("__allArtboards")), ("Security".to_string(), false)); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__save", "value": "file.saveCopy"})); + assert_eq!(r["error"], "the `savePdf` dialog cannot set protected field `__save`", "{r}"); + app.ui.dialog = None; + + // The raster options dialogs share the Resolution menu's Other, PSD Options included. + for kind in ["pngOptions", "tiffOptions", "psdOptions"] { + app.ui.dialog = Some(Dialog::new(kind, json!({"ppi": 72}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__otherPpi", "value": true})); + assert_eq!(r["ok"], true, "{kind}: {r}"); + } + app.ui.dialog = None; + + // A style section's reset flag alone would keep the section's values: scripts run the Reset + // Panel commands, which clear them. Which of the two styles a new style is stays fixed. + for (kind, para, flag) in [ + ("charStyleOptions", false, "__resetColor"), + ("paraStyleOptions", true, "__resetHyphenation"), + ("newTextStyle", false, "__resetColor"), + ("newTextStyle", true, "__resetHyphenation"), + ] { + app.ui.dialog = Some(Dialog::new(kind, json!({"name": "A", "__para": para}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": flag, "value": true})); + assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `{flag}`"), "{r}"); + assert!(!app.ui.dialog.as_ref().is_some_and(|d| d.bool(flag)), "{kind}"); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__para", "value": false})); + assert_eq!(r["error"], "the `newTextStyle` dialog cannot set protected field `__para`", "{r}"); + app.ui.dialog = None; + + crate::prefs_dialog::open(&mut app, None); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__search", "value": "grid"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("__search")), Some("grid".to_string())); +} + /// The refusal of an OK on an install question automation asked. const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; @@ -934,7 +1129,8 @@ fn automation_cannot_approve_replacing_a_plugin() { assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND})); let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#)); let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]})); - assert!(r.get("error").is_none(), "{r}"); + let protected = format!("the `{}` dialog cannot set protected field `__params`", crate::dialogs::plugin::INSTALL_KIND); + assert_eq!(r["error"], protected, "{r}"); super::plugins::show_approval(&mut app); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 640f1e805a..0fdfbc8231 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,7 @@ the two can still win that race. | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..24d264cd38 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -509,7 +509,7 @@ applied colors still join the list while it is hidden. | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | -| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | +| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `set` cannot change a `__` field other than the dialog's controls held under a `__` name (such as Save PDF's `__section` or the Preferences `__search`): the `__command` and `__params` of a `confirm` or `command` dialog, and every other `__` field that holds what OK acts on, are refused with "the `` dialog cannot set protected field ``", so OK runs what the dialog was opened for. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `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?, max_side?, traceViews?}` | 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; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..660051393d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1703,6 +1703,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. +- **A dialog's OK runs what it was opened for:** `ui.dialog.set`, and MCP's `answer_dialog` through it, can set a `__` field only when it is a control of that kind of dialog (a section or tab shown, a link between two fields, a name or search being typed). Every other `__` field, such as the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command` and `__plugin`, or the paste a swatch conflict waits to finish, is refused with "the `` dialog cannot set protected field ``"; the request sets no field, and with `action: "confirm"` OK does not run. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/control/tests.rs`. Scores and hours unchanged. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.