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
@@ -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);+ } } }new file mode 100644@@ -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);+}@@ -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;