From 33b6e11726206967b2172b5bf1b7612880366f54 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:37 -0500 Subject: [PATCH 1/2] Use private directories for print previews --- ROADMAP.md | 3 +- crates/ui-egui/src/app.rs | 3 +- crates/ui-egui/src/app/files.rs | 102 +++++++++++++++++++++++++- crates/ui-egui/src/backstage/tests.rs | 15 +++- crates/ui-egui/src/lib.rs | 1 + 5 files changed, 116 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69..39c1c6ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** trivial (print previews use unique private temporary directories and are cleaned up with the UI app; parity scores unchanged; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-28 | Print previews use unique temporary directories, owner-only on Unix, and are removed when the UI app closes; parity scores unchanged | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8496d0ed..3b3da592 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,7 +13,7 @@ use super::{ mod clipboard; mod commands; mod control; -mod files; +pub(crate) mod files; mod frame; mod ime; mod input; @@ -77,6 +77,7 @@ impl SlideApp { integrated_titlebar: false, backstage: None, pending_urls: vec![], + print_previews: files::PrintPreviews::default(), #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime::default(), pending_copy: None, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 0f3c944f..310956a8 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -3,6 +3,93 @@ use super::{ json, media, }; +#[derive(Default)] +pub(crate) struct PrintPreviews(Vec); + +impl PrintPreviews { + fn create(&mut self, title: &str) -> std::io::Result { + self.create_in(&std::env::temp_dir(), title) + } + + fn create_in(&mut self, base: &std::path::Path, title: &str) -> std::io::Result { + let dir = create_private_print_dir(base)?; + self.0.push(dir.clone()); + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + Ok(dir.join(format!("{safe} (print).pdf"))) + } + + fn discard(&mut self, path: &std::path::Path) { + if let Some(dir) = path.parent() { + let _ = std::fs::remove_dir_all(dir); + self.0.retain(|owned| owned != dir); + } + } +} + +#[cfg(test)] +mod print_preview_tests { + use super::PrintPreviews; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn same_title_gets_distinct_private_paths_and_drop_cleans_them() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let (first, second) = { + let mut previews = PrintPreviews::default(); + let first = previews.create_in(&base, "Same title").unwrap(); + let second = previews.create_in(&base, "Same title").unwrap(); + assert_ne!(first, second); + assert_eq!(first.file_name(), second.file_name()); + assert!(first.parent().is_some_and(|dir| dir.is_dir())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + } + (first, second) + }; + assert!(!first.parent().is_some_and(|dir| dir.exists())); + assert!(!second.parent().is_some_and(|dir| dir.exists())); + std::fs::remove_dir(base).unwrap(); + } +} + +impl Drop for PrintPreviews { + fn drop(&mut self) { + for dir in &self.0 { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +fn create_private_print_dir(base: &std::path::Path) -> std::io::Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_DIR: AtomicU64 = AtomicU64::new(0); + + for _ in 0..128 { + let id = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let path = base.join(format!("deckcraft-print-{}-{id}", std::process::id())); + #[cfg(unix)] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique print preview directory")) +} + fn purpose_of(purpose: &str) -> FilePurpose { match purpose { "open" => FilePurpose::Open, @@ -207,8 +294,14 @@ impl SlideApp { let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); return self.download(&format!("{title}.pdf"), bytes); } - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); + let preview_path = match self.print_previews.create(&title) { + Ok(path) => path, + Err(e) => { + self.set_status(format!("Couldn't create a private print preview: {e}")); + return false; + } + }; + let path = preview_path.to_string_lossy().to_string(); if let Some(o) = params.as_object_mut() { o.insert("path".into(), json!(path)); o.insert("format".into(), json!("pdf")); @@ -219,7 +312,10 @@ impl SlideApp { self.set_status("Opened the presentation as a PDF to print"); true } - Err(_) => false, + Err(_) => { + self.print_previews.discard(&preview_path); + false + } } } diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 78330b1b..30577797 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -618,10 +618,19 @@ fn print_previews_pages_and_prints_a_pdf() { h.run_steps(2); let url = h.state().pending_urls.first().cloned().unwrap_or_default(); // The URL was handed to egui at the end of the frame; check what it pointed at. - let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title())); - assert!(printed.is_file(), "the PDF to print was written: {url}"); + assert!(url.contains("deckcraft-print-"), "print preview uses its private directory: {url}"); + let title = h.state().session.active().unwrap().title(); + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + let file_name = format!("{safe} (print).pdf"); + let printed = std::fs::read_dir(std::env::temp_dir()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("deckcraft-print-")) + .map(|entry| entry.path().join(&file_name)) + .find(|path| path.is_file()) + .expect("the PDF to print was written in its private directory"); assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF")); - let _ = std::fs::remove_file(printed); + let _ = std::fs::remove_dir_all(printed.parent().unwrap()); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30b5a0cd..fd3deaa4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -278,6 +278,7 @@ pub struct SlideApp { /// creating or closing a presentation leaves it. pub backstage: Option, pending_urls: Vec, + print_previews: app::files::PrintPreviews, #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime, /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame. From b4c1905d7815bca00d04c33e8952f3359f03e46d Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:51:34 -0500 Subject: [PATCH 2/2] dc-fix-1519: address print preview review --- apps/deckcraft/src/main.rs | 2 + crates/ui-egui/src/app.rs | 5 ++ crates/ui-egui/src/app/files.rs | 83 ++++++++++++++++----------- crates/ui-egui/src/backstage/tests.rs | 31 +++++----- 4 files changed, 75 insertions(+), 46 deletions(-) diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 03859dc0..ad3f820f 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -60,6 +60,8 @@ impl eframe::App for App { // nothing after it is needed: the preferences are saved and the log file is unbuffered. // `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117. #[cfg(target_os = "macos")] + self.0.cleanup_print_previews(); + #[cfg(target_os = "macos")] std::process::exit(0); } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3b3da592..7c9c373a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -30,6 +30,11 @@ pub use keys::key_name; const STATUS_LINGER_MS: f64 = 6000.0; impl SlideApp { + /// Remove temporary PDFs created for printing. + pub fn cleanup_print_previews(&mut self) { + self.print_previews.cleanup(); + } + /// Build application UI state, taking ownership of audio output and initialising media, dialogs, /// synthetic input, and timing fields. pub fn new(session: Session, mut services: Services) -> Self { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 310956a8..1e846e0d 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -24,44 +24,17 @@ impl PrintPreviews { self.0.retain(|owned| owned != dir); } } -} -#[cfg(test)] -mod print_preview_tests { - use super::PrintPreviews; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn same_title_gets_distinct_private_paths_and_drop_cleans_them() { - let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); - let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id())); - std::fs::create_dir(&base).unwrap(); - let (first, second) = { - let mut previews = PrintPreviews::default(); - let first = previews.create_in(&base, "Same title").unwrap(); - let second = previews.create_in(&base, "Same title").unwrap(); - assert_ne!(first, second); - assert_eq!(first.file_name(), second.file_name()); - assert!(first.parent().is_some_and(|dir| dir.is_dir())); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o700); - } - (first, second) - }; - assert!(!first.parent().is_some_and(|dir| dir.exists())); - assert!(!second.parent().is_some_and(|dir| dir.exists())); - std::fs::remove_dir(base).unwrap(); + pub(crate) fn cleanup(&mut self) { + for dir in self.0.drain(..) { + let _ = std::fs::remove_dir_all(dir); + } } } impl Drop for PrintPreviews { fn drop(&mut self) { - for dir in &self.0 { - let _ = std::fs::remove_dir_all(dir); - } + self.cleanup(); } } @@ -761,3 +734,49 @@ pub(crate) fn web_image_files( }) .collect() } + +#[cfg(test)] +mod print_preview_tests { + use super::PrintPreviews; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn same_title_gets_distinct_private_paths_and_drop_cleans_them() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let (first, second) = { + let mut previews = PrintPreviews::default(); + let first = previews.create_in(&base, "Same title").unwrap(); + let second = previews.create_in(&base, "Same title").unwrap(); + assert_ne!(first, second); + assert_eq!(first.file_name(), second.file_name()); + assert!(first.parent().is_some_and(|dir| dir.is_dir())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + } + (first, second) + }; + assert!(!first.parent().is_some_and(|dir| dir.exists())); + assert!(!second.parent().is_some_and(|dir| dir.exists())); + std::fs::remove_dir(base).unwrap(); + } + + #[test] + fn explicit_cleanup_removes_previews() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-cleanup-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let mut previews = PrintPreviews::default(); + let path = previews.create_in(&base, "Explicit cleanup").unwrap(); + let dir = path.parent().unwrap().to_path_buf(); + + previews.cleanup(); + + assert!(!dir.exists()); + std::fs::remove_dir(base).unwrap(); + } +} diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 276461d0..22a27972 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -634,21 +634,24 @@ fn print_previews_pages_and_prints_a_pdf() { h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into(); h.run_steps(2); click_on_page(&h, "Print"); - h.run_steps(2); - let url = h.state().pending_urls.first().cloned().unwrap_or_default(); - // The URL was handed to egui at the end of the frame; check what it pointed at. + h.step(); + let url = h + .output() + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::OpenUrl(open) => Some(open.url.as_str()), + _ => None, + }) + .expect("Print emits an egui OpenUrl command"); assert!(url.contains("deckcraft-print-"), "print preview uses its private directory: {url}"); - let title = h.state().session.active().unwrap().title(); - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let file_name = format!("{safe} (print).pdf"); - let printed = std::fs::read_dir(std::env::temp_dir()) - .unwrap() - .filter_map(Result::ok) - .filter(|entry| entry.file_name().to_string_lossy().starts_with("deckcraft-print-")) - .map(|entry| entry.path().join(&file_name)) - .find(|path| path.is_file()) - .expect("the PDF to print was written in its private directory"); - assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF")); + let path = url.strip_prefix("file://").expect("print preview URL is a file URL"); + #[cfg(windows)] + let path = path.strip_prefix('/').unwrap_or(path); + let printed = std::path::Path::new(path); + assert!(printed.is_file(), "the PDF at the emitted URL was written: {url}"); + assert!(std::fs::read(printed).unwrap().starts_with(b"%PDF")); let _ = std::fs::remove_dir_all(printed.parent().unwrap()); let _ = std::fs::remove_dir_all(&dir); }