From 12085b1153a1004879de2a23a4d38e95aa57b46b Mon Sep 17 00:00:00 2001 From: cohenmendy Date: Mon, 12 Oct 2026 17:26:50 -0400 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01WxAm8VMKHa5w6kwkjBaT3D --- Cargo.lock | 12 +++++++ apps/deckcraft/Cargo.toml | 7 ++++ apps/deckcraft/src/apple_events.rs | 52 ++++++++++++++++++++++++++++++ apps/deckcraft/src/main.rs | 40 +++++++++++++++++++++-- packaging/macos/Info.plist.in | 37 +++++++++++++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 apps/deckcraft/src/apple_events.rs diff --git a/Cargo.lock b/Cargo.lock index e2bc7965..a8bd9e93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml index 26ea3081..fb3b489c 100644 --- a/apps/deckcraft/Cargo.toml +++ b/apps/deckcraft/Cargo.toml @@ -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"] } diff --git a/apps/deckcraft/src/apple_events.rs b/apps/deckcraft/src/apple_events.rs new file mode 100644 index 00000000..4b583ad0 --- /dev/null +++ b/apps/deckcraft/src/apple_events.rs @@ -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), + } + } +} diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index ef80daaa..b842c0f3 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -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, args: impl IntoIterator "--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()]); + } + } } diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 22afe13e..bc1fd72e 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,6 +45,43 @@ NSHumanReadableCopyright Copyright © the DeckCraft authors. MIT OR Apache-2.0. + + CFBundleDocumentTypes + + + CFBundleTypeName + DeckCraft Presentation + CFBundleTypeRole + Editor + LSHandlerRank + Owner + CFBundleTypeIconFile + DeckCraft + LSItemContentTypes + + ai.storyteller.deckcraft.presentation + + + + CFBundleTypeName + PowerPoint Presentation + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + org.openxmlformats.presentationml.presentation + + CFBundleTypeExtensions + pptx + CFBundleTypeMIMETypes + application/vnd.openxmlformats-officedocument.presentationml.presentation + + + UTExportedTypeDeclarations From 9632162029616f76b6a328f3e6d39e08fa18b002 Mon Sep 17 00:00:00 2001 From: Lexx143 Date: Tue, 13 Oct 2026 15:54:58 +0300 Subject: [PATCH 2/2] macOS: offer .potx and .ppsx too, keep the desktops' file types equal, log the change Windows (deckcraft.wxs) and Linux (the desktop file) already offer DeckCraft for .deckcraft, .pptx, .potx and .ppsx; the macOS Info.plist now declares the same four, so a template or a slide show opens from Finder as well. A test in apps/deckcraft reads the three packaging files and fails when they drift apart, and checks every document type names its content type (Finder's "Change All" needs it). Progress log row for #138 and #347. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 2 + apps/deckcraft/src/main.rs | 73 +++++++++++++++++++++++++++++++++++ packaging/macos/Info.plist.in | 70 ++++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7dfa96f8..ca31acbb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -195,6 +195,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read | | 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it | | 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) | +| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -280,6 +281,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | trivial | Progress log: Grid Options dialog | | 2026-10-14 | trivial | Progress log: pen pressure scales ink width | | 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) | +| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-13 | trivial | Progress log: slide show for interactive decks | | 2026-10-12 | minor | Progress log: table cell editing and border resizing | diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index b842c0f3..83245bb7 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -343,4 +343,77 @@ mod tests { assert_eq!(files, ["talk.pptx".to_string()]); } } + + /// The `` values of every `` that follows a `{key}` line. + fn plist_strings(plist: &str, key: &str) -> Vec { + let mut out = Vec::new(); + let mut in_array = false; + for line in plist.lines() { + let line = line.trim(); + if line == format!("{key}") { + in_array = true; + continue; + } + if in_array { + for part in line.split("").skip(1) { + if let Some((s, _)) = part.split_once("") { + out.push(s.to_string()); + } + } + if line.ends_with("") { + 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("CFBundleTypeName").count(); + let content_types = plist.matches("LSItemContentTypes").count(); + assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes"); + + let mut win: Vec = 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 = 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"); + } } diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index bc1fd72e..d668a561 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -47,7 +47,9 @@ + preferred app for its formats unless the user picks DeckCraft. The same set as the Windows + installer (deckcraft.wxs) and the Linux desktop file; a test in apps/deckcraft keeps + them equal. --> CFBundleDocumentTypes @@ -80,6 +82,38 @@ CFBundleTypeMIMETypes application/vnd.openxmlformats-officedocument.presentationml.presentation + + CFBundleTypeName + PowerPoint Template + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + org.openxmlformats.presentationml.template + + CFBundleTypeExtensions + potx + CFBundleTypeMIMETypes + application/vnd.openxmlformats-officedocument.presentationml.template + + + CFBundleTypeName + PowerPoint Slide Show + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + org.openxmlformats.presentationml.slideshow + + CFBundleTypeExtensions + ppsx + CFBundleTypeMIMETypes + application/vnd.openxmlformats-officedocument.presentationml.slideshow + @@ -126,6 +160,40 @@ pptx + + UTTypeIdentifier + org.openxmlformats.presentationml.template + UTTypeDescription + Open XML Presentation Template + UTTypeConformsTo + + public.data + public.presentation + public.zip-archive + + UTTypeTagSpecification + + public.filename-extension + potx + + + + UTTypeIdentifier + org.openxmlformats.presentationml.slideshow + UTTypeDescription + Open XML Slide Show + UTTypeConformsTo + + public.data + public.presentation + public.zip-archive + + UTTypeTagSpecification + + public.filename-extension + ppsx + +