Merge pull request #82 from storytold/fix/73-macos-open-from-finder

macOS: open PDFs from Finder, Open With and the Dock (#73)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-07 02:17:21 +07:00
commit f3e22b738d
12 files changed
+157 -76

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);
}
@@ -12,7 +12,8 @@
</developer>
<description>
<p>
PrintCraft is a pdf editor. Read, organize, combine, split and secure PDFs
PrintCraft is an open-source PDF workbench. Read, comment on, fill in and sign PDFs;
organize, combine and split pages; edit, redact, protect and export documents.
</p>
</description>
<launchable type="desktop-id">ai.storyteller.printcraft.desktop</launchable>
@@ -23,20 +24,19 @@
<url type="help">https://github.com/storytold/printcraft</url>
<url type="contact">https://discord.gg/artcraft</url>
<categories>
<category>Graphics</category>
<category>RasterGraphics</category>
<category>Photography</category>
<category>Office</category>
<category>Viewer</category>
</categories>
<keywords>
<keyword>photo</keyword>
<keyword>image editor</keyword>
<keyword>psd</keyword>
<keyword>layers</keyword>
<keyword>pdf</keyword>
<keyword>viewer</keyword>
<keyword>annotate</keyword>
<keyword>forms</keyword>
</keywords>
<screenshots>
<screenshot type="default">
<caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption>
<image>https://raw.githubusercontent.com/storytold/printcraft/main/docs/images/printcraft-demo.jpg</image>
<caption>A PDF open with its threaded comments</caption>
<image>https://raw.githubusercontent.com/storytold/printcraft/main/docs/images/printcraft-viewer.png</image>
</screenshot>
</screenshots>
<branding>
@@ -53,6 +53,7 @@
</supports>
<provides>
<binary>printcraft</binary>
<mediatype>application/pdf</mediatype>
</provides>
<content_rating type="oars-1.1"/>
<releases>
+3 -4
View File
@@ -17,10 +17,9 @@ vendor: Learning Machines LLC
homepage: https://getartcraft.com/apps/printcraft
license: MIT OR Apache-2.0
description: |-
Open-source, native image editor that works the way Photoshop users expect.
Layers, masks, adjustment layers, layer styles, type and brushes; opens and
saves layered PSD/PSB files; 8/16/32-bit documents; GPU compositing (wgpu).
Includes printcraft-cli, a headless converter and command runner.
Open-source, native PDF workbench: read, comment, fill and sign forms,
organize, combine, split, edit, redact, protect and export PDFs.
Includes printcraft-cli, a headless command runner and opt-in MCP server.
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
+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>
+1 -1
View File
@@ -55,7 +55,7 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
```html
<iframe
src="https://example.com/printcraft/"
title="PrintCraft image editor"
title="PrintCraft PDF workbench"
style="width: 100%; height: 720px; border: 0;"
allow="fullscreen; clipboard-read; clipboard-write"
allowfullscreen>
+2 -3
View File
@@ -4,9 +4,8 @@
wix build printcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by PrintCraft,
and PrintCraft offered under "Open with" for PSD/PSB and the image formats it reads (without
taking over the user's default app for them).
Per-machine install into Program Files, a Start Menu shortcut and an App Paths entry. Files
opened with PrintCraft arrive on its command line.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
+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"