From 1ddfb56f666c1ebd635682b6a5539b62122c32a5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:52:19 +0000 Subject: [PATCH] Attributes: the Browser button opens http, https and mailto URLs only attributes.openUrl refuses other URL schemes with a message. It and links.editOriginal hand a URL or file to another app, so they answer a control or MCP request (and synthetic input) with an error; a placed document still opens in a tab. --- crates/ui-egui/src/lib.rs | 30 +++++++++++++++++++++ crates/ui-egui/src/menus.rs | 4 +-- crates/ui-egui/src/panels/attributes.rs | 36 ++++++++++++++++++++++++- crates/ui-egui/src/panels/links.rs | 1 + crates/ui-egui/src/tests_automation.rs | 36 +++++++++++++++++++++++++ docs/control-protocol.md | 5 +++- docs/mcp.md | 3 ++- 7 files changed, 110 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b6c58414f..6467d4302 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -200,6 +200,18 @@ pub type DownloadFn = Box Result<(), String>>; /// Opens a URL in the system browser. pub type OpenUrlFn = Box; +/// Whether a URL from a document may open in the browser: an `http:`, `https:` or `mailto:` URL +/// without control characters. The system hands other schemes (`file:`, application schemes) to +/// other programs. +pub(crate) fn browsable_url(url: &str) -> Result<(), String> { + let scheme = url.split_once(':').map_or("", |(s, _)| s); + if ["http", "https", "mailto"].iter().any(|k| scheme.eq_ignore_ascii_case(k)) && !url.chars().any(char::is_control) { + Ok(()) + } else { + Err(format!("only http, https and mailto URLs open in the browser, not “{url}”")) + } +} + /// Shows a file in the system file manager, or opens it in its app. pub type RevealFn = Box Result<(), String>>; @@ -766,6 +778,24 @@ impl VectorcraftApp { self.ui.status = format!("Opened {url}"); } + /// Open `url`, which came from a document (an object's URL attribute), in the browser: + /// [`browsable_url`]s only. + pub(crate) fn open_document_url(&mut self, url: &str) -> Result<(), String> { + browsable_url(url)?; + self.open_url(url); + Ok(()) + } + + /// Refuse UI command `id`, which hands something to another app, when automation sent it (a + /// control or MCP request, or a frame of synthetic input): the automation roots don't confine + /// what that app does. + pub(crate) fn refuse_automation(&self, id: &str) -> Result<(), String> { + if self.ui.answering_control || self.synthetic_frame { + return Err(format!("{id} opens another app: only the person at the keyboard can run it")); + } + Ok(()) + } + /// Run a Help link command and open the URL it returns. pub fn open_link(&mut self, id: &str) { if let Ok(v) = self.run(id, serde_json::json!({})) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7ba3ff6ad..f087a6f4d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -622,7 +622,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "attributes.openUrl", "Browser", "", - "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button) → {url}", + "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}", ), ( "ui.spotColors", @@ -733,7 +733,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "links.editOriginal", "Edit Original", "", - "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", ), ( "links.reveal", diff --git a/crates/ui-egui/src/panels/attributes.rs b/crates/ui-egui/src/panels/attributes.rs index 58fdfb7b8..ad3fdd7fa 100644 --- a/crates/ui-egui/src/panels/attributes.rs +++ b/crates/ui-egui/src/panels/attributes.rs @@ -161,11 +161,12 @@ pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { /// `attributes.openUrl {url?}`: open `url`, else the selection's URL, in the web browser. pub(crate) fn open_url(app: &mut VectorcraftApp, p: &Value) -> Result { + app.refuse_automation("attributes.openUrl")?; let url = match p.get("url").and_then(Value::as_str) { Some(u) => u.to_string(), None => app.session.attributes_info().url.filter(|u| !u.is_empty()).ok_or("the selection has no URL (or they differ)")?, }; - app.open_url(&url); + app.open_document_url(&url)?; Ok(json!({ "url": url })) } @@ -223,4 +224,37 @@ mod tests { assert!(!texts.iter().any(|t| t == "https://example.com/a" || t == "first note"), "{texts:?}"); assert!(open_url(&mut app, &json!({})).is_err()); } + + /// Only web and mail URLs reach the browser. + #[test] + fn the_browser_opens_web_and_mail_urls_only() { + for ok in ["https://example.com/a", "HTTP://example.com", "mailto:someone@example.com"] { + assert!(crate::browsable_url(ok).is_ok(), "{ok}"); + } + for refused in [ + "file:///tmp/a.html", + "javascript:void(0)", + "ms-settings:", + "smb://host/share", + "x-app://run", + "example.com", + " https://example.com", + "https://a\nb", + "", + ] { + assert!(crate::browsable_url(refused).is_err(), "{refused}"); + } + let opened = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let o = opened.clone(); + let services = crate::Services { open_url: Some(Box::new(move |u: &str| o.borrow_mut().push(u.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); + run(&mut app, "attributes.set", json!({"url": "file:///tmp/run.desktop"})); + assert!(open_url(&mut app, &json!({})).is_err()); + assert!(open_url(&mut app, &json!({"url": "vscode://file/x"})).is_err()); + assert!(opened.borrow().is_empty()); + open_url(&mut app, &json!({"url": "https://example.com/b"})).unwrap(); + assert_eq!(*opened.borrow(), ["https://example.com/b"]); + } } diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 73ecc53ba..2e030bb01 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -208,6 +208,7 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re if reveal { crate::io::reveal_path(app, &path)?; } else { + app.refuse_automation("links.editOriginal")?; openable_original(&path)?; crate::io::open_in_app(app, &path)?; } diff --git a/crates/ui-egui/src/tests_automation.rs b/crates/ui-egui/src/tests_automation.rs index cbfa2f251..2ce1cd6c7 100644 --- a/crates/ui-egui/src/tests_automation.rs +++ b/crates/ui-egui/src/tests_automation.rs @@ -118,3 +118,39 @@ fn frames_with_injected_input_are_confined() { assert!(!app.synthetic_frame, "the frames after it are the person's"); let _ = std::fs::remove_dir_all(base); } + +/// Edit Original and the Attributes panel's Browser button hand a file or URL to another app: a +/// control request (or MCP, which comes through it) can't run them, roots or not. +#[test] +fn automation_cannot_hand_files_or_urls_to_other_apps() { + let base = std::env::temp_dir().join(format!("vc-ui-handoff-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let png = base.join("linked.png"); + image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])).save(&png).unwrap(); + let opened = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let (u, f) = (opened.clone(), opened.clone()); + let services = Services { + open_url: Some(Box::new(move |p: &str| u.borrow_mut().push(p.to_string()))), + open_file: Some(Box::new(move |p: &str| { + f.borrow_mut().push(p.to_string()); + Ok(()) + })), + ..Default::default() + }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let image = app.run("file.place", json!({"path": s(png.clone())})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("attributes.set", json!({"url": "https://example.com/a"})).unwrap(); + for (command, params) in [("attributes.openUrl", json!({})), ("links.editOriginal", json!({"id": image}))] { + let r = request(&mut app, &tx, "engine.execute", json!({"command": command, "params": params})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("only the person at the keyboard")), "{command}: {r}"); + } + assert!(opened.borrow().is_empty()); + // The person at the keyboard can. + app.run("attributes.openUrl", json!({})).unwrap(); + app.run("links.editOriginal", json!({"id": image})).unwrap(); + assert_eq!(opened.borrow().len(), 2); + let _ = std::fs::remove_dir_all(base); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d8ce4596c..63584dd2e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -405,6 +405,8 @@ one undo step. Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its controls run `attributes.set`, `path.reverse {reversed}` and `path.setFillRule {rule}`, and its Browser button runs the UI command `attributes.openUrl {url?}`, which opens the URL (default: the selection's) in the web browser and answers `{url}`. +It opens `http:`, `https:` and `mailto:` URLs only, and answers a control request with an error: it hands the URL to +another app. Spot Colors: the Swatches panel menu's Spot Colors… (`ui.spotColors`) opens the `spotColors` dialog (field `useLab`: true shows and separates spot colours defined in Lab from their Lab values, false from their working-CMYK @@ -598,7 +600,8 @@ Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids? `fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's `links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to -the system and return `{path}`. +the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open` +reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder), diff --git a/docs/mcp.md b/docs/mcp.md index b6c980a1c..1e8e9875b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2547,7 +2547,8 @@ file is missing stays linked: relink it first); `links.unembed {id, path}` write `links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and -`links.reveal` open the linked file in its app or show it in its folder (desktop). +`links.reveal` open the linked file in its app or show it in its folder (desktop). Edit Original runs only from the app's window, +except for a placed document, which opens in a tab. ```json {"name":"run_command","arguments":{"command":"links.list","params":{"show":"missing"}}}