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"