From f50c5a4e88cf1cc4a3482e34237352c4635cb807 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 01:39:33 +0700 Subject: [PATCH] macOS: open PDFs from Finder, Open With and the Dock (#73) Finder hands files to an app as an Apple event, not on the command line. winit doesn't handle it, so AppKit answered "PrintCraft cannot open files in the PDF document format". The bundle also declared no PDF document type: its Info.plist still carried the sibling image editor's types. - Info.plist declares PDF (by extension and MIME type) as a document type, role Editor, rank Alternate: offered under Open With and settable as the default, without taking over on install. - apps/printcraft registers open-documents and quit Apple event handlers through fmv-macos-events (=0.1.0, the audited safe wrapper PhotoCraft uses, since the Objective-C interop needs unsafe), before the launch event arrives. - printcraft-ui-egui gains OsEvent and PrintCraftApp::os_events, polled every frame: Open opens each file as File > Open does; Quit closes the window like its close button, so unsaved changes are asked about. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 +++++ apps/printcraft/Cargo.toml | 6 +++ apps/printcraft/src/apple_events.rs | 46 +++++++++++++++++++ apps/printcraft/src/main.rs | 12 +++++ crates/ui-egui/src/lib.rs | 27 +++++++++++ crates/ui-egui/tests/ui.rs | 20 ++++++++ packaging/macos/Info.plist.in | 71 ++++++----------------------- parity/acrobat-features.toml | 4 +- 8 files changed, 140 insertions(+), 58 deletions(-) create mode 100644 apps/printcraft/src/apple_events.rs diff --git a/Cargo.lock b/Cargo.lock index b65554b7..a6efac42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1787,6 +1787,17 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3771,6 +3782,7 @@ version = "0.2.1" dependencies = [ "eframe", "egui", + "fmv-macos-events", "printcraft-ui-egui", "rustls-native-certs", "serde_json", diff --git a/apps/printcraft/Cargo.toml b/apps/printcraft/Cargo.toml index 0a07903b..e0215885 100644 --- a/apps/printcraft/Cargo.toml +++ b/apps/printcraft/Cargo.toml @@ -18,6 +18,12 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " egui = { workspace = true } serde_json = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +# Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited +# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) +# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. +fmv-macos-events = "=0.1.0" + [build-dependencies] # Icon + VERSIONINFO for printcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" diff --git a/apps/printcraft/src/apple_events.rs b/apps/printcraft/src/apple_events.rs new file mode 100644 index 00000000..37f8aaf6 --- /dev/null +++ b/apps/printcraft/src/apple_events.rs @@ -0,0 +1,46 @@ +//! macOS open-documents and quit Apple events (#73). +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a PrintCraft file.pdf` don't +//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`, so AppKit answered "PrintCraft cannot open files in the PDF document +//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this +//! workspace forbids. The audited `fmv-macos-events` crate (also used by PhotoCraft) wraps exactly +//! that, an `NSAppleEventManager` handler registered before Finder's launch event that leaves +//! winit's delegate alone, behind a safe main-thread API. This module adapts it to the +//! [`OsEvent`]s that the UI polls every frame. + +use fmv_macos_events::{Event, Inbox, Registration}; +use printcraft_ui_egui::{OsEvent, OsEventsFn}; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the UI drains (`PrintCraftApp::os_events`); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + let inbox = self.inbox.clone(); + Box::new(move || { + inbox + .drain() + .into_iter() + .map(|e| match e { + Event::Open(paths) => OsEvent::Open(paths.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()), + Event::Quit => OsEvent::Quit, + }) + .collect() + }) + } +} diff --git a/apps/printcraft/src/main.rs b/apps/printcraft/src/main.rs index 390b3c0a..cf796360 100644 --- a/apps/printcraft/src/main.rs +++ b/apps/printcraft/src/main.rs @@ -19,6 +19,8 @@ use printcraft_ui_egui::PrintCraftApp; +#[cfg(target_os = "macos")] +mod apple_events; mod updates; /// Freedesktop app id: the `.desktop` file name and the hicolor icon name. @@ -79,6 +81,12 @@ fn main() -> eframe::Result { let persistence_path = eframe::storage_dir("PrintCraft").map(|d| d.join("app.ron")); let mut native = eframe::NativeOptions { viewport, persistence_path, ..Default::default() }; configure_gpu(&mut native); + // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one + // that launched us as well as later ones. Lives until the event loop returns. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; eframe::run_native( "PrintCraft", native, @@ -90,6 +98,10 @@ fn main() -> eframe::Result { app.integrated_titlebar = integrated; app.update_source = Some(std::sync::Arc::new(updates::latest_release)); app.keychain_ids = cfg!(target_os = "macos"); + #[cfg(target_os = "macos")] + { + app.os_events = Some(apple_events.connect(&cc.egui_ctx)); + } if let Some(file) = &control_file { let client = app.attach_control(&cc.egui_ctx); match printcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14277be3..a1e3419a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -253,6 +253,19 @@ pub struct PageClip { /// Files delivered asynchronously: (name, bytes). pub type Inbox = std::sync::Arc)>>>; +/// A request the operating system sends the running app, outside its window: on macOS, Finder +/// double-clicks, Open With and drops on the Dock icon arrive as Apple events, not arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OsEvent { + /// Open these files. + Open(Vec), + /// Quit (the Dock's Quit, logging out), asking about unsaved changes first. + Quit, +} + +/// Returns the [`OsEvent`]s that arrived since it was last called (set by the desktop app). +pub type OsEventsFn = Box Vec>; + pub struct PasswordPrompt { pub name: String, pub path: Option, @@ -300,6 +313,8 @@ pub struct PrintCraftApp { pub full_screen: bool, /// Files delivered asynchronously (web drag-and-drop, web file picker). pub inbox: Inbox, + /// Requests from the operating system, polled every frame (macOS Apple events). + pub os_events: Option, /// A pending "save changes?" question (closing a dirty tab or quitting). pub close_request: Option, /// Save to this path instead of asking (tests and automation). @@ -470,6 +485,7 @@ impl PrintCraftApp { password_prompt: None, full_screen: false, inbox: Default::default(), + os_events: None, close_request: None, save_override: None, props_draft: None, @@ -1141,6 +1157,17 @@ impl eframe::App for PrintCraftApp { self.notify(format!("Couldn't open {name}: {e}")); } } + let os_events = self.os_events.as_mut().map(|poll| poll()).unwrap_or_default(); + for e in os_events { + match e { + #[cfg(not(target_arch = "wasm32"))] + OsEvent::Open(paths) => paths.iter().for_each(|p| self.open_path(p)), + #[cfg(target_arch = "wasm32")] + OsEvent::Open(_) => {} + // Like closing the window: `guard_quit` asks about unsaved changes. + OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } if let Some(mut control) = self.control.take() { control.tick(ctx, self); self.control = Some(control); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 7fab48f5..1925a673 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -240,3 +240,23 @@ fn dropping_a_pdf_on_the_window_opens_it() { assert_eq!(h.state().views.len(), 1, "the dropped PDF opens in a tab"); h.get_by_label_contains("dropped.pdf"); } + +#[test] +fn files_and_quit_from_the_operating_system() { + // #73: macOS hands Finder double-clicks, Open With and Dock drops over as Apple events. + use printcraft_ui_egui::OsEvent; + let name = format!("printcraft-os-open-{}.pdf", std::process::id()); + let path = std::env::temp_dir().join(&name); + std::fs::write(&path, FIXTURE).unwrap(); + let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Open(vec![path.to_string_lossy().into_owned()])])); + let q = queue.clone(); + let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + std::fs::remove_file(&path).ok(); + assert_eq!(h.state().views.len(), 1, "the file opens in a tab"); + h.get_by_label_contains(&name); + // Quit closes the window like the close button, so unsaved changes are asked about. + queue.borrow_mut().push(OsEvent::Quit); + h.step(); + let closing = h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close))); + assert!(closing); +} diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 5067ab89..c83f1bca 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,64 +45,23 @@ NSHumanReadableCopyright Copyright © the PrintCraft authors. MIT OR Apache-2.0. - - UTExportedTypeDeclarations + + CFBundleDocumentTypes - UTTypeIdentifier - ai.storyteller.printcraft.document - UTTypeDescription - PrintCraft Document - UTTypeConformsTo - - public.data - public.image - - UTTypeIconFile - PrintCraft - UTTypeTagSpecification - - public.filename-extension - pcraft - public.mime-type - application/x-printcraft - - - - - UTImportedTypeDeclarations - - - UTTypeIdentifier - com.adobe.photoshop-large-image - UTTypeDescription - Photoshop Large Document - UTTypeConformsTo - - public.data - public.image - - UTTypeTagSpecification - - public.filename-extension - psb - - - - UTTypeIdentifier - ai.storyteller.printcraft.qoi - UTTypeDescription - QOI Image - UTTypeConformsTo - - public.data - public.image - - UTTypeTagSpecification - - public.filename-extension - qoi - + CFBundleTypeName + PDF Document + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + pdf + CFBundleTypeMIMETypes + application/pdf diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6b5c936f..dcf898fe 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -22,8 +22,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks"] -notes = "" +evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system"] +notes = "File ▸ Open, the command line (Linux and Windows file associations), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type)" [[feature]] id = "core.repair-broken-files"