mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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!({}))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"}}}
|
||||
|
||||
Reference in New Issue
Block a user