From 2fec613353624a248d65030154584b2ebd4d88e5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:42:43 -0500 Subject: [PATCH] Place: the place cursor reads its files within the roots it was loaded in An agent confined to automation roots could load the place cursor, with file.place.queue or tool.setOption, and the files were placed later, by a click of the person at the keyboard, outside any roots. tool.setOption checked a queued path but not files given inline, whose linked files (an SVG's referenced images) were then read with the keyboard user's unrestricted access. The session now keeps the automation roots in force when the cursor's queue is loaded, and both the engine's own placement and the desktop app's background placement read the queued file within them. A queue loaded outside any roots places as before. --- crates/engine/src/lib.rs | 5 ++++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/place.rs | 33 ++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 26 ++++++++++++++++++++ crates/ui-egui/src/place.rs | 17 ++++++++----- 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0eed45b34..177697839 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -163,6 +163,11 @@ pub struct Session { /// While [`Session::place_queue_read`] runs `file.place.queue`: the files read for it, which /// the command loads the place cursor with instead of reading them; `None` otherwise. pub(crate) queue_hand_off: Option, + /// The automation roots in force when the place cursor was last loaded (`None`: none were). + /// The cursor places its files later, on a click that may come from the person at the + /// keyboard: those files, and the files they link to, are read under these roots, so a queue + /// an agent loaded never reads beyond what it was given. + pub(crate) place_queue_roots: Option>, /// While [`Session::import_image_read`] runs `clipboard.importImage`: the image read for it, /// which the command loads instead of reading `dataBase64`; `None` otherwise. pub(crate) image_hand_off: Option, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 71233cea6..51b5b5776 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -79,6 +79,7 @@ impl Session { batch_steps: None, place_hand_off: None, queue_hand_off: None, + place_queue_roots: None, image_hand_off: None, trace_hand_off: None, place_cursor_requests: false, diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index 7c3ff56ab..3c86e513e 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -722,6 +722,39 @@ fn a_queue_of_3_with_click_drag_and_esc_places_2() { assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement"); } +/// A place cursor loaded within automation roots places its files within them, even on a later +/// click outside any roots (the person at the keyboard's): a queued file outside the roots, or +/// one it links to, is not read then. One loaded outside any roots places as before. +#[test] +fn the_place_cursor_places_within_the_roots_it_was_loaded_in() { + let base = std::env::temp_dir().join(format!("vectorcraft-place-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("in")).unwrap(); + let outside = base.join("outside.png"); + std::fs::write(&outside, png(40, 20, [9, 9, 9, 255], None)).unwrap(); + let entry = json!({"params": {"path": outside.to_string_lossy()}, "name": "outside.png", "width": 40, "height": 20}); + let roots = crate::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap(); + let view = ViewInfo::default(); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), view)?; + s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), view) + }; + let mut s = session(); + s.select_tool("place", view).unwrap(); + // Loaded within the roots (as a tool option, past the check tool.setOption makes of paths). + crate::file_access::confine(Some(&roots), || s.set_tool_option("queue", &json!({"entries": [entry]}))); + assert!(s.place_queue_roots().is_some()); + let placed = crate::file_access::unconfined(|| click(&mut s)); + assert!(top_children(&s).is_empty(), "{placed:?}"); + // Loaded outside any roots: the click places it. + s.select_tool("place", view).unwrap(); + crate::file_access::unconfined(|| s.set_tool_option("queue", &json!({"entries": [entry]}))); + assert!(s.place_queue_roots().is_none()); + crate::file_access::unconfined(|| click(&mut s)).unwrap(); + assert_eq!(top_children(&s).len(), 1); + let _ = std::fs::remove_dir_all(base); +} + /// With [`Session::place_cursor_requests`] set, a click of the place cursor asks the frontend to /// place its file ([`UiRequest::Place`]) and places nothing itself; the params it asks with place /// the file as the click places it without the flag. diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index ecbc45964..d5c03a651 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -658,11 +658,27 @@ impl Session { self.tool.options() } pub fn set_tool_option(&mut self, key: &str, v: &Value) { + if key == "queue" && self.tool.id() == "place" { + self.note_place_queue_roots(); + } self.tool.set_option(key, v); self.tool_set_since_reset = true; self.keep_tool_settings(); } + /// Keep the automation roots in force now as those the place cursor's queue, being loaded, + /// is placed under ([`Session::place_queue_roots`]). + fn note_place_queue_roots(&mut self) { + self.place_queue_roots = crate::file_access::current(); + } + + /// The automation roots the place cursor's files are placed under: those in force when it + /// was loaded. A frontend that places them itself ([`UiRequest::Place`]) reads them within + /// these roots ([`crate::file_access::confine`]). + pub fn place_queue_roots(&self) -> Option> { + self.place_queue_roots.clone() + } + /// The options of tool `id`: the active tool's, or those it would have if chosen now. pub fn tool_options_of(&self, id: &str) -> Value { if id == self.tool.id() { self.tool.options() } else { self.make_tool(id).options() } @@ -673,6 +689,9 @@ impl Session { /// Liquify tools' Global Brush Dimensions, the Smooth tool's Fidelity for Pencil, Paintbrush /// and Blob Brush Alt smoothing). Returns the tool's options. pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map) -> Value { + if values.contains_key("queue") && id.unwrap_or(self.tool.id()) == "place" { + self.note_place_queue_roots(); + } let active = id.is_none_or(|id| id == self.tool.id()); self.tool_set_since_reset |= active; let mut other = (!active).then(|| self.make_tool(id.unwrap_or_default())); @@ -842,6 +861,13 @@ impl Session { } // The place cursor's file, placed by the frontend that asked for it. Action::Exec(cmd, p) if cmd == "file.place" && self.place_cursor_requests => ui.push(UiRequest::Place(p)), + // The place cursor's file, read within the roots of whoever loaded the cursor. + Action::Exec(cmd, p) if cmd == "file.place" => { + let roots = self.place_queue_roots.clone(); + if let Some(w) = crate::file_access::confine(roots.as_ref(), || self.execute(&cmd, &p))?.get("warning").and_then(Value::as_str) { + ui.push(UiRequest::Status(w.to_string())); + } + } Action::Exec(cmd, p) => { // A command's warning (Crop Image's when the image couldn't be decoded) goes to the // status bar too. diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index c2b920b83..d4f4412ec 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -376,13 +376,18 @@ fn queue_files(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result