`application:openURLs:` can pass URLs that aren't files, and on macOS 27 `to_file_path()` returns
the path of an https URL ("/a.svg"), so it was queued as a document. Keep the file URLs
(`isFileURL`) before converting; `opened_files_wait_for_the_ui` passes again on macOS 27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
5.0 KiB
Rust
107 lines
5.0 KiB
Rust
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
|
|
//! `open -a VectorCraft file`.
|
|
//!
|
|
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
|
|
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
|
|
//! which fails with "VectorCraft cannot open files in the … format" because VectorCraft isn't an
|
|
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
|
|
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
|
|
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
|
|
//! wakes the UI, which opens them with [`take`] like files named on the command line.
|
|
//!
|
|
//! Adding a method to another crate's Objective-C class has no safe API, hence the scoped
|
|
//! `unsafe_code` allowance (winit overrides `sendEvent:` on `NSApplication` the same way).
|
|
#![allow(unsafe_code)]
|
|
|
|
use std::ptr::NonNull;
|
|
use std::sync::{Mutex, OnceLock, PoisonError};
|
|
|
|
use block2::RcBlock;
|
|
use objc2::MainThreadMarker;
|
|
use objc2::runtime::{AnyObject, Imp, Sel};
|
|
use objc2::sel;
|
|
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
|
|
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
|
|
|
|
/// Paths that arrived and haven't been opened yet.
|
|
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
|
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
|
static UI: OnceLock<egui::Context> = OnceLock::new();
|
|
|
|
/// Handle open-document events from now on. Call before the event loop runs.
|
|
pub fn install() {
|
|
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
|
|
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
|
|
// runs on the posting thread (AppKit posts it on the main thread). The notification center
|
|
// keeps the registration (and the block) for the life of the app.
|
|
unsafe {
|
|
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
|
Some(NSApplicationWillFinishLaunchingNotification),
|
|
None,
|
|
None,
|
|
&block,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Wake `ctx` when files arrive while the app runs.
|
|
pub fn set_ui(ctx: &egui::Context) {
|
|
// Set once; a second call would hand over the same context.
|
|
let _ = UI.set(ctx.clone());
|
|
}
|
|
|
|
/// The paths that arrived since the last call.
|
|
pub fn take() -> Vec<String> {
|
|
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
|
}
|
|
|
|
/// `application:openURLs:` on the application delegate's class.
|
|
fn add_open_urls_method() {
|
|
let Some(mtm) = MainThreadMarker::new() else { return };
|
|
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
|
|
log::warn!("no application delegate; files opened from Finder won't open");
|
|
return;
|
|
};
|
|
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
|
|
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
|
|
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
|
|
// it with the arguments the type encoding declares, which match `open_urls`: no return value
|
|
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
|
|
let added = unsafe {
|
|
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
|
|
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
|
|
};
|
|
if !added.as_bool() {
|
|
log::warn!("the application delegate already handles open-document events");
|
|
}
|
|
}
|
|
|
|
/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit.
|
|
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
|
|
// Only file URLs: macOS 27 turns an https URL's path into a file path too (#433).
|
|
let paths = urls.iter().filter(|u| u.isFileURL()).filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
|
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
|
|
if let Some(ctx) = UI.get() {
|
|
ctx.request_repaint();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use objc2::runtime::NSObject;
|
|
use objc2_foundation::NSString;
|
|
|
|
use super::*;
|
|
|
|
/// Files queue until the UI takes them; URLs that aren't files are left out.
|
|
#[test]
|
|
fn opened_files_wait_for_the_ui() {
|
|
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.svg")).unwrap();
|
|
let urls = NSArray::from_retained_slice(&[NSURL::from_file_path("/tmp/a b.pdf").unwrap(), web, NSURL::from_file_path("/tmp/c.ai").unwrap()]);
|
|
let any = NSObject::new();
|
|
open_urls(&any, sel!(application:openURLs:), &any, &urls);
|
|
assert_eq!(take(), ["/tmp/a b.pdf", "/tmp/c.ai"]);
|
|
assert!(take().is_empty());
|
|
}
|
|
}
|