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.
This commit is contained in:
aiongg
2026-10-16 16:52:19 +00:00
parent b46ffd0059
commit 1ddfb56f66
7 changed files with 110 additions and 5 deletions
+30
View File
@@ -200,6 +200,18 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
/// Opens a URL in the system browser.
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
/// 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<dyn FnMut(&str) -> 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!({}))
+2 -2
View File
@@ -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",
+35 -1
View File
@@ -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<Value, String> {
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::<String>::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"]);
}
}
+1
View File
@@ -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)?;
}
+36
View File
@@ -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::<String>::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);
}
+4 -1
View File
@@ -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),
+2 -1
View File
@@ -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"}}}