Merge pull request #432 from Lexx143/fix/macos-finder-open

macOS: open presentations from Finder, Open With and the Dock (#138, #347)
This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-10-21 10:39:36 -06:00
commit 9ad2cf3be8
6 files changed
+288 -3

No files matched your search

+7
View File
@@ -29,5 +29,12 @@ arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
cpal = "0.16"
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
# GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which
# our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+52
View File
@@ -0,0 +1,52 @@
//! macOS open-documents and quit Apple events.
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DeckCraft deck.pptx` 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`. Handling it ourselves needs Objective-C class declarations, i.e.
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate (also used by
//! GridCraft, PdfCraft and 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.
use deckcraft_ui_egui::SlideApp;
use fmv_macos_events::{Event, Inbox, Registration};
/// 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 app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
if let Err(e) = app.open_path(p) {
log::error!("{p}: {e}");
app.set_status(format!("Couldn't open {p}: {e}"));
}
}
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
+110 -3
View File
@@ -14,6 +14,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_os = "macos")]
mod apple_events;
mod audio;
mod control_server;
#[cfg(all(unix, not(target_os = "macos")))]
@@ -27,10 +29,17 @@ mod pasteboard;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
struct App(SlideApp);
struct App(
SlideApp,
/// Files opened from Finder and quit requests (see `apple_events`).
#[cfg(target_os = "macos")]
fmv_macos_events::Inbox,
);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.1, &mut self.0, ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -158,6 +167,8 @@ fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>
"--show" => show = true,
"--version" | "-V" => return Startup::Version,
"--help" | "-h" => return Startup::Help,
// The process serial number older macOS adds when Finder launches the app.
a if a.starts_with("-psn_") => {}
_ => files.push(a),
}
}
@@ -234,6 +245,12 @@ fn main() -> eframe::Result {
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
eframe::run_native(
"DeckCraft",
options,
@@ -264,7 +281,11 @@ fn main() -> eframe::Result {
if show {
app.start_show(0, false);
}
Ok(Box::new(App(app)))
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
}),
)
}
@@ -278,7 +299,11 @@ mod tests {
#[test]
fn on_exit_ends_the_process_on_macos() {
if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() {
let mut app = App(SlideApp::new(Session::new(), Services::default()));
let mut app = App(
SlideApp::new(Session::new(), Services::default()),
#[cfg(target_os = "macos")]
fmv_macos_events::Inbox::default(),
);
println!("calling on_exit");
eframe::App::on_exit(&mut app);
println!("on_exit returned");
@@ -311,4 +336,86 @@ mod tests {
assert!(!show);
}
}
#[test]
fn finder_process_serial_number_is_not_a_file() {
let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]);
assert!(matches!(launch, Startup::Run { .. }));
if let Startup::Run { files, .. } = launch {
assert_eq!(files, ["talk.pptx".to_string()]);
}
}
/// The `<string>` values of every `<array>` that follows a `<key>{key}</key>` line.
fn plist_strings(plist: &str, key: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_array = false;
for line in plist.lines() {
let line = line.trim();
if line == format!("<key>{key}</key>") {
in_array = true;
continue;
}
if in_array {
for part in line.split("<string>").skip(1) {
if let Some((s, _)) = part.split_once("</string>") {
out.push(s.to_string());
}
}
if line.ends_with("</array>") {
in_array = false;
}
}
}
out
}
/// Every desktop offers the same file types: macOS (`CFBundleTypeExtensions` and the native
/// type's extension in `Info.plist.in`), Windows (`SupportedTypes` in `deckcraft.wxs`) and
/// Linux (`MimeType` in the desktop file). A format added to one installer must reach the
/// others, and each must be one Finder, Explorer or the file manager can hand to DeckCraft.
#[test]
fn every_desktop_offers_the_same_file_types() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
let desktop = include_str!("../../../packaging/linux/ai.storyteller.deckcraft.desktop");
let mut mac = plist_strings(plist, "CFBundleTypeExtensions");
// The native type is declared by its UTI; its extension is in the exported type.
mac.extend(plist_strings(plist, "public.filename-extension").into_iter().filter(|e| e == "deckcraft"));
mac.sort();
mac.dedup();
assert!(mac.iter().any(|e| e == "pptx") && mac.iter().any(|e| e == "deckcraft"), "Info.plist.in: {mac:?}");
// Every document type names a content type too, so "Change All" in Finder's Get Info works.
let names = plist.matches("<key>CFBundleTypeName</key>").count();
let content_types = plist.matches("<key>LSItemContentTypes</key>").count();
assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes");
let mut win: Vec<String> = wxs
.lines()
.filter(|l| l.contains("SupportedTypes\" Name=\"."))
.filter_map(|l| l.split("Name=\".").nth(1)?.split('"').next().map(str::to_string))
.collect();
win.sort();
let mime_ext = |m: &str| match m {
"application/x-deckcraft" => Some("deckcraft"),
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
"application/vnd.openxmlformats-officedocument.presentationml.template" => Some("potx"),
"application/vnd.openxmlformats-officedocument.presentationml.slideshow" => Some("ppsx"),
_ => None,
};
let mut linux: Vec<String> = desktop
.lines()
.find_map(|l| l.strip_prefix("MimeType="))
.unwrap_or_default()
.split(';')
.filter(|m| !m.is_empty())
.map(|m| mime_ext(m).unwrap_or(m).to_string()) // an unknown MIME type shows up in the diff
.collect();
linux.sort();
assert_eq!(mac, win, "macOS and Windows offer different file types");
assert_eq!(mac, linux, "macOS and Linux offer different file types");
}
}