From 2237fdbab38c8fdf95d2295f6aa99fe4c03c76cb Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 31 Mar 2026 17:32:57 -0700 Subject: [PATCH] Handle macOS kAEOpenDocuments for Finder Open With support --- crates/cord-gui/Cargo.toml | 4 ++ crates/cord-gui/src/app.rs | 13 +++- crates/cord-gui/src/main.rs | 126 ++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/crates/cord-gui/Cargo.toml b/crates/cord-gui/Cargo.toml index 56ad806..c7f5e8e 100644 --- a/crates/cord-gui/Cargo.toml +++ b/crates/cord-gui/Cargo.toml @@ -29,3 +29,7 @@ dirs = "6" arboard = "3" zip = "2" muda = "0.17" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSAppleEventDescriptor", "NSAppleEventManager", "NSString"] } diff --git a/crates/cord-gui/src/app.rs b/crates/cord-gui/src/app.rs index 1471312..7682b63 100644 --- a/crates/cord-gui/src/app.rs +++ b/crates/cord-gui/src/app.rs @@ -6,9 +6,15 @@ use iced::widget::{ use iced::{Background, Border, Color, Element, Fill, Length, Padding, Shadow, Subscription}; use iced::{mouse, keyboard, window, Point, Vector}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; +static OPEN_QUEUE: OnceLock>> = OnceLock::new(); + +pub fn open_queue() -> &'static Mutex> { + OPEN_QUEUE.get_or_init(|| Mutex::new(Vec::new())) +} + use cord_expr::{classify, classify_from, expr_to_sdf, parse_expr, parse_expr_scene, ExprInfo}; use crate::highlight::{CordHighlighter, CordHighlighterSettings, format_token}; use crate::viewport::SdfViewport; @@ -334,6 +340,11 @@ impl App { self.menu_ready = true; } self.poll_menu_events(); + if let Ok(mut q) = open_queue().lock() { + for path in q.drain(..) { + self.open_path(&path); + } + } let new_line = self.source.cursor().position.line; if new_line != self.cursor_line { self.cursor_line = new_line; diff --git a/crates/cord-gui/src/main.rs b/crates/cord-gui/src/main.rs index 0f4b176..c79dcb6 100644 --- a/crates/cord-gui/src/main.rs +++ b/crates/cord-gui/src/main.rs @@ -14,7 +14,133 @@ fn title(app: &App) -> String { app.title() } +#[cfg(target_os = "macos")] +mod apple_events { + use std::path::PathBuf; + use objc2::rc::Retained; + use objc2::runtime::{AnyObject, NSObject}; + use objc2::{define_class, msg_send, sel, AnyThread}; + + // kCoreEventClass = 'aevt', kAEOpenDocuments = 'odoc', keyDirectObject = '----' + const KAEVT: u32 = u32::from_be_bytes(*b"aevt"); + const KODOC: u32 = u32::from_be_bytes(*b"odoc"); + const KEY_DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----"); + + define_class!( + #[unsafe(super(NSObject))] + #[name = "CordOpenHandler"] + struct OpenHandler; + + impl OpenHandler { + #[unsafe(method(handleOpenEvent:withReplyEvent:))] + fn handle_open_event(&self, event: &AnyObject, _reply: &AnyObject) { + handle_odoc(event); + } + } + ); + + impl OpenHandler { + fn new() -> Retained { + let this = Self::alloc().set_ivars(()); + unsafe { msg_send![super(this), init] } + } + } + + fn handle_odoc(event: &AnyObject) { + let file_list: Option> = unsafe { + msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT] + }; + let file_list = match file_list { + Some(fl) => fl, + None => return, + }; + + let count: isize = unsafe { msg_send![&*file_list, numberOfItems] }; + let queue = crate::app::open_queue(); + + for i in 1..=count { + let desc: Option> = unsafe { + msg_send![&*file_list, descriptorAtIndex: i] + }; + let desc = match desc { + Some(d) => d, + None => continue, + }; + let url_str: Option> = unsafe { + msg_send![&*desc, stringValue] + }; + let url_str = match url_str { + Some(s) => s, + None => continue, + }; + let raw: *const std::ffi::c_char = unsafe { msg_send![&*url_str, UTF8String] }; + if raw.is_null() { + continue; + } + let cstr = unsafe { std::ffi::CStr::from_ptr(raw) }; + let s = match cstr.to_str() { + Ok(s) => s, + Err(_) => continue, + }; + let path_str = if let Some(stripped) = s.strip_prefix("file://") { + percent_decode(stripped) + } else { + s.to_string() + }; + let path = PathBuf::from(&path_str); + if path.exists() { + if let Ok(mut q) = queue.lock() { + q.push(path); + } + } + } + } + + fn percent_decode(input: &str) -> String { + let mut out = Vec::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix( + &input[i + 1..i + 3], + 16, + ) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| input.to_string()) + } + + pub fn register_open_handler() { + use objc2_foundation::NSAppleEventManager; + + let handler = OpenHandler::new(); + let mgr = NSAppleEventManager::sharedAppleEventManager(); + let sel = sel!(handleOpenEvent:withReplyEvent:); + unsafe { + let _: () = msg_send![ + &*mgr, + setEventHandler: &*handler, + andSelector: sel, + forEventClass: KAEVT, + andEventID: KODOC + ]; + } + // Leak the handler so it lives for the process lifetime + std::mem::forget(handler); + } +} + fn main() -> iced::Result { + #[cfg(target_os = "macos")] + apple_events::register_open_handler(); + iced::application(App::new, App::update, App::view) .title(title) .theme(theme)