mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
2e49a84ace
commit
f50c5a4e88
8 files changed
+140
-58
No files matched your search
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user