mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-22 18:22:13 +00:00
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:
6 files changed
+288
-3
No files matched your search
@@ -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"] }
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user