fix(macos): open .pptx and .deckcraft files from Finder

The macOS bundle declared the .deckcraft and .pptx types but listed no
CFBundleDocumentTypes, so DeckCraft never appeared under Open With and
couldn't be made the default app for presentations. Finder opens also
arrive as a kAEOpenDocuments Apple event, not argv, which winit doesn't
handle, so a double-clicked file wouldn't have loaded anyway.

- Info.plist.in: document types for DeckCraft Presentation (Owner) and
  PowerPoint Presentation (Alternate, so PowerPoint/Keynote stay
  preferred unless the user picks DeckCraft).
- apple_events.rs: register an open-documents handler with the pinned
  fmv-macos-events crate GridCraft already uses, and open the paths each
  frame; failures show in the status bar.
- Ignore the -psn_ argument older macOS passes on a Finder launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxAm8VMKHa5w6kwkjBaT3D
This commit is contained in:
cohenmendy
2026-10-17 12:17:45 +05:00
committed by Lexx143
co-authored by Claude Opus 5.5
parent 1dc69720db
commit 12085b1153
5 changed files with 145 additions and 3 deletions
Generated
+12
View File
@@ -901,6 +901,7 @@ dependencies = [
"deckcraft-ui-egui",
"eframe",
"egui",
"fmv-macos-events",
"image",
"log",
"rfd",
@@ -1630,6 +1631,17 @@ dependencies = [
"num-traits",
]
[[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.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
+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),
}
}
}
+37 -3
View File
@@ -13,6 +13,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")))]
@@ -26,10 +28,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) {
@@ -155,6 +164,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),
}
}
@@ -231,6 +242,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,
@@ -262,7 +279,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),
)))
}),
)
}
@@ -276,7 +297,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");
@@ -309,4 +334,13 @@ 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()]);
}
}
}
+37
View File
@@ -45,6 +45,43 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © the DeckCraft authors. MIT OR Apache-2.0.</string>
<!-- The documents DeckCraft opens, so Finder's Open With and "Change All" can offer it (the
Apple events arrive through apps/deckcraft/src/apple_events.rs). PowerPoint stays the
preferred app for .pptx unless the user picks DeckCraft. -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>DeckCraft Presentation</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>CFBundleTypeIconFile</key>
<string>DeckCraft</string>
<key>LSItemContentTypes</key>
<array>
<string>ai.storyteller.deckcraft.presentation</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>PowerPoint Presentation</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>org.openxmlformats.presentationml.presentation</string>
</array>
<key>CFBundleTypeExtensions</key>
<array><string>pptx</string></array>
<key>CFBundleTypeMIMETypes</key>
<array><string>application/vnd.openxmlformats-officedocument.presentationml.presentation</string></array>
</dict>
</array>
<!-- DeckCraft's own format: a zip of a JSON manifest plus media. -->
<key>UTExportedTypeDeclarations</key>
<array>