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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-07 01:39:33 +07:00
1 parent 2e49a84ace
commit f50c5a4e88
8 files changed
+140 -58

No files matched your search

Generated
+12
View File
@@ -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",
+6
View File
@@ -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"
+46
View File
@@ -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()
})
}
}
+12
View File
@@ -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)) {
+27
View File
@@ -253,6 +253,19 @@ pub struct PageClip {
/// Files delivered asynchronously: (name, bytes).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// 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<String>),
/// 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<dyn FnMut() -> Vec<OsEvent>>;
pub struct PasswordPrompt {
pub name: String,
pub path: Option<String>,
@@ -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<OsEventsFn>,
/// A pending "save changes?" question (closing a dirty tab or quitting).
pub close_request: Option<CloseRequest>,
/// 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);
+20
View File
@@ -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);
}
+15 -56
View File
@@ -45,64 +45,23 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © the PrintCraft authors. MIT OR Apache-2.0.</string>
<!-- Printcraft's own format. -->
<key>UTExportedTypeDeclarations</key>
<!-- Documents PrintCraft opens (#73): it is offered under Open With and can be made the default
for PDFs, without taking over from the system's viewer on install ("Alternate"). Declared by
extension and MIME type; LaunchServices maps them to the system's PDF type. Finder hands the
file over as an Apple event, which apps/printcraft/src/apple_events.rs receives. -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.printcraft.document</string>
<key>UTTypeDescription</key>
<string>PrintCraft Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeIconFile</key>
<string>PrintCraft</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>pcraft</string></array>
<key>public.mime-type</key>
<array><string>application/x-printcraft</string></array>
</dict>
</dict>
</array>
<!-- Types macOS may not know; the system's own declaration wins when it has one. -->
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.photoshop-large-image</string>
<key>UTTypeDescription</key>
<string>Photoshop Large Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>psb</string></array>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.printcraft.qoi</string>
<key>UTTypeDescription</key>
<string>QOI Image</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>qoi</string></array>
</dict>
<key>CFBundleTypeName</key>
<string>PDF Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array><string>pdf</string></array>
<key>CFBundleTypeMIMETypes</key>
<array><string>application/pdf</string></array>
</dict>
</array>
+2 -2
View File
@@ -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"