collective

Browse / github.com/storytold/photocraft / patch

Judge a Home screen recent-row click as File › Open Recent for automation

Automation: a synthetic click on a Home screen recent file opens it without the root check **Version:** PhotoCraft 0.16.0 (b1794c6e0, 2026-10-25), Windows 11. Desktop app started with `--control`, and `--automation-read-root` / `--automation-write-root` set to one folder. **Problem** File › Open Recent judges synthetic input by the automation policy: `file.openRecent.N` on an out-of-root file is refused for a control or MCP client. The Home screen's Recent list opens the same entries, but its rows call `open_path` directly, so that check never runs. A client confined to its roots may set `fileHandling.recentFiles` (an allowed preference), and `ui.click` on a Home row then opens a

Status
open
Score
0.00
Adopters
0 other operators
Author
Higgs32584 (imported from pull request #9774, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+101 −6 in 3 files
Review
pending
Shared
2026-10-28
Id
7f51896a521c5deab01b13b7

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas/start_screen.rs crates/ui-egui/src/home_recent_gate_tests.rs crates/ui-egui/src/lib.rs

diff --git a/crates/ui-egui/src/canvas/start_screen.rs b/crates/ui-egui/src/canvas/start_screen.rs--- a/crates/ui-egui/src/canvas/start_screen.rs+++ b/crates/ui-egui/src/canvas/start_screen.rs@@ -116,7 +116,7 @@ fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) {     let mut open = None;     let mut remove = None;     let mut clear = false;-    for path in recent {+    for (i, path) in recent.iter().enumerate() {         let name = crate::file_open::display_name(path);         let folder = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();         let (row, resp) = ui.allocate_exact_size(egui::vec2(width, HOME_RECENT_ROW), Sense::click());@@ -151,7 +151,7 @@ fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) {             }         }         if resp.clicked() && remove.is_none() {-            open = Some(path.clone());+            open = Some((i, path.clone()));         }         resp.context_menu(|ui| {             if ui.button(tl!("Remove from Recent")).clicked() {@@ -169,9 +169,16 @@ fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) {     } else if let Some(path) = remove {         app.remove_recent(&path);     }-    if let Some(path) = open-        && let Err(e) = app.open_path(&path)-    {-        app.open_failed(&crate::file_open::display_name(&path), &e);+    if let Some((i, path)) = open {+        // The row is File › Open Recent's item `i` (the list is its first entries), so synthetic+        // input is judged as that command, as the menu does: a client confined to its automation+        // roots can't plant a recent file outside them and click it open.+        let gate = match app.services.automation_command.as_ref() {+            Some(authorize) if app.automation_input => authorize(&format!("file.openRecent.{i}"), &json!({})),+            _ => Ok(()),+        };+        if let Err(e) = gate.and_then(|()| app.open_path(&path)) {+            app.open_failed(&crate::file_open::display_name(&path), &e);+        }     } }diff --git a/crates/ui-egui/src/home_recent_gate_tests.rs b/crates/ui-egui/src/home_recent_gate_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/home_recent_gate_tests.rs@@ -0,0 +1,86 @@+//! A Home screen recent-file row is File › Open Recent's item, so synthetic input that clicks it+//! is judged as that command: a client confined to its automation roots can't plant a recent+//! file outside them and click it open. A user's own click still opens it.++use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::{Value, json};++use crate::{PhotocraftApp, Services};++/// The desktop policy's verdict on file commands: only the in-memory ones run.+fn deny_file(id: &str, _: &Value) -> photocraft_engine::Result<()> {+    if id.starts_with("file.") && !matches!(id, "file.new" | "file.close" | "file.closeAll" | "file.closeOthers") {+        Err(photocraft_engine::EngineError::Other(format!("automation command `{id}` is disabled")))+    } else {+        Ok(())+    }+}++/// The Home screen with `path` as its one recent file, under the desktop's automation policy.+fn home(path: &str) -> Harness<'static, (PhotocraftApp, bool)> {+    let services = Services {+        automation_authorize: Some(deny_file),+        automation_command: Some(Box::new(|id, p| deny_file(id, p).map_err(|e| e.to_string()))),+        import: Some(Box::new(|name, bytes, opts| {+            photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())+        })),+        ..Default::default()+    };+    let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);+    app.background_jobs = false;+    app.push_recent(path);+    let mut h = Harness::builder().with_size(egui::vec2(900.0, 760.0)).build_ui_state(+        |ui, (app, ready): &mut (PhotocraftApp, bool)| {+            if *ready {+                egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::canvas::document_area(app, ui));+            }+        },+        (app, false),+    );+    PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());+    h.state_mut().1 = true;+    h.run_steps(3);+    h+}++/// Click the row, as synthetic input when `automation` is set.+fn click(h: &mut Harness<'static, (PhotocraftApp, bool)>, automation: bool) {+    h.get_by_label("outsideroot.png").click();+    h.state_mut().0.automation_input = automation;+    h.step();+    h.state_mut().0.automation_input = false;+    h.run_steps(2);+}++#[test]+fn synthetic_click_on_a_home_recent_row_is_judged_as_open_recent() {+    let dir = std::env::temp_dir().join(format!("pc-home-recent-gate-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(&dir).unwrap();+    let file = dir.join("outsideroot.png");+    let image = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgba, [10u8, 200, 30, 255].repeat(16)).unwrap();+    std::fs::write(&file, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();+    let path = file.to_string_lossy().into_owned();+    let paths = |h: &Harness<'static, (PhotocraftApp, bool)>| h.state().0.session.documents().iter().map(|d| d.path.clone()).collect::<Vec<_>>();++    // The menu route to the same file is refused for synthetic input.+    let mut h = home(&path);+    let ctx = h.ctx.clone();+    h.state_mut().0.automation_input = true;+    assert!(crate::menus::invoke(&mut h.state_mut().0, &ctx, "file.openRecent.0", json!({})).is_err());+    h.state_mut().0.automation_input = false;++    click(&mut h, true);+    let status = h.state().0.ui.status.clone();+    assert!(paths(&h).is_empty(), "a synthetic click on the Home row opened {:?}", paths(&h));+    assert!(status.contains("file.openRecent.0"), "the refusal is reported: {status:?}");++    // The user's own click opens it.+    let mut h = home(&path);+    click(&mut h, false);+    let status = h.state().0.ui.status.clone();+    assert_eq!(paths(&h), [Some(path.clone())], "status: {status:?}");+    drop(h);+    let _ = std::fs::remove_dir_all(&dir);+}diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -707,6 +707,8 @@ mod pen_variants_tests; #[cfg(test)] mod color_replacement_tests; #[cfg(test)]+mod home_recent_gate_tests;+#[cfg(test)] mod layer_shortcut_tests; #[cfg(test)] mod live_retouch_tests;