use std::ffi::c_void; use std::path::Path; use std::sync::Arc; use anyhow::Result; use cocoa::base::{id, nil, BOOL, NO, YES}; use galaxyui_core::accessibility::AccessibilityContent; use galaxyui_core::clipboard::InMemoryClipboard; use galaxyui_core::keymap::Keystroke; use galaxyui_core::modals::{AlertDialog, ModalId}; use galaxyui_core::notification::{ NotificationSendError, RequestPermissionsOutcome, UserNotification, }; use galaxyui_core::platform::{ Cursor, FilePickerCallback, FilePickerConfiguration, MicrophoneAccessState, SendNotificationErrorCallback, TerminationMode, }; use galaxyui_core::{platform, ApplicationBundleInfo, WindowId}; use objc2::{msg_send, MainThreadMarker}; use objc2_app_kit::{NSApplication, NSCursor, NSRequestUserAttentionType}; use objc2_av_foundation::{AVAuthorizationStatus, AVCaptureDevice, AVMediaTypeAudio}; use objc2_foundation::NSUInteger; use super::app::create_native_platform_modal; use super::keycode::{modifier_code, Keycode}; use super::utils::nsstring_as_str; use super::{app, make_nsstring, Clipboard, Window}; // Functions implemented in objC files. extern "C" { // Requests permissions to send desktop notifications. fn requestNotificationPermissions(on_completion_callback: *const c_void); // Sends a desktop notification. fn sendNotification( title: id, body: id, data: id, on_error_callback: *const c_void, play_sound: BOOL, ); fn isDarkMode() -> BOOL; fn registerGlobalHotkey(key_code: NSUInteger, modifiers_key: NSUInteger); fn unregisterGlobalHotkey(key_code: NSUInteger, modifiers_key: NSUInteger); fn executableInApplicationBundleWithIdentifier(bundle_path: id) -> id; fn absolutePathForApplicationBundleWithIdentifier(bundle_identifier: id) -> id; fn isVoiceOverEnabled() -> BOOL; } type RequestNotificationPermissionsCallback = Box; type NotificationSendErrorCallback = Box; /// Delegator that wraps platform-specific calls in a common API. pub struct AppDelegate { clipboard: Clipboard, dispatch_delegate: Arc, } pub struct IntegrationTestDelegate { app_delegate: AppDelegate, clipboard: InMemoryClipboard, } impl IntegrationTestDelegate { pub fn new() -> Result { Ok(IntegrationTestDelegate { app_delegate: AppDelegate::new()?, clipboard: InMemoryClipboard::default(), }) } } impl platform::Delegate for IntegrationTestDelegate { #[cfg(feature = "test-util")] fn get_cursor_shape(&self) -> Cursor { self.app_delegate.get_cursor_shape() } fn set_cursor_shape(&self, cursor: Cursor) { self.app_delegate.set_cursor_shape(cursor) } fn open_url(&self, _: &str) { // no-op } fn open_file_path(&self, _: &Path) { // no-op } fn open_file_path_in_explorer(&self, _: &Path) { // no-op } fn open_file_picker( &self, _callback: FilePickerCallback, _file_picker_config: FilePickerConfiguration, ) { // no-op } fn open_save_file_picker( &self, _callback: platform::SaveFilePickerCallback, _config: platform::SaveFilePickerConfiguration, ) { // no-op } fn application_bundle_info(&self, _: &str) -> Option> { None } fn close_ime_async(&self, _window_id: WindowId) { // no-op } fn is_ime_open(&self) -> bool { false } fn open_character_palette(&self) { // no-op } fn set_accessibility_contents(&self, _: AccessibilityContent) { // no-op } fn request_user_attention(&self, _window_id: WindowId) { // no-op } fn clipboard(&mut self) -> &mut dyn crate::Clipboard { &mut self.clipboard } fn request_desktop_notification_permissions( &self, _on_completion: platform::RequestNotificationPermissionsCallback, ) { // no-op } fn send_desktop_notification( &self, _notification_content: UserNotification, _window_id: WindowId, _on_error: SendNotificationErrorCallback, ) { } fn system_theme(&self) -> platform::SystemTheme { self.app_delegate.system_theme() } fn dispatch_delegate(&self) -> Arc { self.app_delegate.dispatch_delegate() } fn register_global_shortcut(&self, shortcut: Keystroke) { self.app_delegate.register_global_shortcut(shortcut) } fn unregister_global_shortcut(&self, shortcut: &Keystroke) { self.app_delegate.unregister_global_shortcut(shortcut) } fn terminate_app(&self, termination_mode: TerminationMode) { self.app_delegate.terminate_app(termination_mode); } fn is_screen_reader_enabled(&self) -> Option { self.app_delegate.is_screen_reader_enabled() } fn microphone_access_state(&self) -> MicrophoneAccessState { self.app_delegate.microphone_access_state() } fn show_native_platform_modal(&self, _id: ModalId, _modal: AlertDialog) { // no-op } } pub struct DispatchDelegate; impl AppDelegate { pub fn new() -> Result { Ok(AppDelegate { clipboard: Clipboard::new()?, dispatch_delegate: Arc::new(DispatchDelegate), }) } } impl platform::Delegate for AppDelegate { /// Sets the cursor shape /// See https://developer.apple.com/documentation/appkit/nscursor?language=objc fn set_cursor_shape(&self, cursor: Cursor) { let cursor = match cursor { Cursor::Arrow => NSCursor::arrowCursor(), Cursor::IBeam => NSCursor::IBeamCursor(), Cursor::Crosshair => NSCursor::crosshairCursor(), Cursor::OpenHand => NSCursor::openHandCursor(), Cursor::NotAllowed => NSCursor::operationNotAllowedCursor(), Cursor::PointingHand => NSCursor::pointingHandCursor(), Cursor::ResizeLeftRight => NSCursor::resizeLeftRightCursor(), Cursor::ResizeUpDown => NSCursor::resizeUpDownCursor(), Cursor::ClosedHand => NSCursor::closedHandCursor(), Cursor::DragCopy => NSCursor::dragCopyCursor(), }; cursor.set(); } #[cfg(feature = "test-util")] fn get_cursor_shape(&self) -> Cursor { unimplemented!("only implemented in tests") } fn open_url(&self, url: &str) { Window::open_url(url); } fn open_file_path(&self, path: &Path) { Window::open_file_path(path); } fn open_file_path_in_explorer(&self, path: &Path) { Window::open_file_path_in_explorer(path); } fn open_file_picker( &self, callback: FilePickerCallback, file_picker_config: FilePickerConfiguration, ) { Window::open_file_picker(callback, file_picker_config); } fn open_save_file_picker( &self, callback: platform::SaveFilePickerCallback, config: platform::SaveFilePickerConfiguration, ) { Window::open_save_file_picker(callback, config); } fn application_bundle_info( &self, bundle_identifier: &str, ) -> Option> { let bundle_path = unsafe { let nsstring = absolutePathForApplicationBundleWithIdentifier(make_nsstring(bundle_identifier)); if nsstring == nil { return None; } nsstring_as_str(nsstring).ok()? }; let executable_path = unsafe { let nsstring = executableInApplicationBundleWithIdentifier(make_nsstring(bundle_path)); if nsstring == nil { None } else { nsstring_as_str(nsstring).map(Path::new).ok() } }; Some(ApplicationBundleInfo { path: Path::new(bundle_path), executable: executable_path, }) } /// Open the macOS character palette. fn open_character_palette(&self) { // Open the character palette in a async task on the main thread to // ensure we don't double-borrow the app. dispatch::Queue::main().exec_async(move || { // See https://developer.apple.com/documentation/appkit/nsapplication/1428455-orderfrontcharacterpalette. // If the `sender` argument is nil, the palette is shown relative to the // first responder's cursor location. In our case, that will be the Warp // host view, with a location set via the `active_cursor_position` API. // SAFETY: the closure runs on the main dispatch queue. let mtm = unsafe { MainThreadMarker::new_unchecked() }; NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None); }); } fn close_ime_async(&self, window_id: WindowId) { Window::close_ime_async(window_id); } fn is_ime_open(&self) -> bool { Window::is_ime_open() } fn set_accessibility_contents(&self, content: AccessibilityContent) { Window::set_accessibility_contents(content); } fn request_user_attention(&self, _window_id: WindowId) { // SAFETY: delegate methods run on the main thread. let mtm = unsafe { MainThreadMarker::new_unchecked() }; let _ = NSApplication::sharedApplication(mtm) .requestUserAttention(NSRequestUserAttentionType::InformationalRequest); } fn request_desktop_notification_permissions( &self, on_completion_callback: platform::RequestNotificationPermissionsCallback, ) { unsafe { let callback: RequestNotificationPermissionsCallback = Box::new(|outcome| { app::callback_dispatcher().with_mutable_app_context(|ctx| { on_completion_callback(outcome, ctx); }) }); requestNotificationPermissions(Box::into_raw(Box::new(callback)) as *const c_void); }; } fn send_desktop_notification( &self, notification_content: UserNotification, _window_id: WindowId, on_error_callback: SendNotificationErrorCallback, ) { unsafe { let callback: NotificationSendErrorCallback = Box::new(|error| { app::callback_dispatcher().with_mutable_app_context(|ctx| { on_error_callback(error, ctx); }) }); sendNotification( make_nsstring(notification_content.title()), make_nsstring(notification_content.body()), make_nsstring(notification_content.data().unwrap_or_default()), Box::into_raw(Box::new(callback)) as *const c_void, if notification_content.play_sound() { YES } else { NO }, ); }; } fn clipboard(&mut self) -> &mut dyn crate::Clipboard { &mut self.clipboard } fn system_theme(&self) -> platform::SystemTheme { unsafe { let dark_mode = isDarkMode(); if dark_mode == YES { platform::SystemTheme::Dark } else { platform::SystemTheme::Light } } } fn dispatch_delegate(&self) -> Arc { self.dispatch_delegate.clone() } fn show_native_platform_modal(&self, id: ModalId, modal: AlertDialog) { let alert = create_native_platform_modal(modal); // `ModalId` is `#[repr(transparent)]` over `usize`, matching the // `showModal:modalId:` selector's `NSUInteger` parameter. // SAFETY: `ModalId` and `NSUInteger` share the same representation. let modal_id: NSUInteger = unsafe { std::mem::transmute(id) }; // SAFETY: `showModal:modalId:` is a custom warp NSApplication selector. unsafe { let app = &*app::get_warp_app().cast::(); let _: () = msg_send![app, showModal: &*alert, modalId: modal_id]; } } fn register_global_shortcut(&self, shortcut: Keystroke) { unsafe { for shortcut_key in Keycode::keycodes_from_key_name(&shortcut.key) { registerGlobalHotkey(shortcut_key.0.into(), modifier_code(&shortcut).into()); } } } fn unregister_global_shortcut(&self, shortcut: &Keystroke) { unsafe { for shortcut_key in Keycode::keycodes_from_key_name(&shortcut.key) { unregisterGlobalHotkey(shortcut_key.0.into(), modifier_code(shortcut).into()); } } } fn set_dock_icon_visible(&self, visible: bool) { dispatch::Queue::main().exec_async(move || { // SAFETY: the closure runs on the main dispatch queue. let mtm = unsafe { MainThreadMarker::new_unchecked() }; let app = NSApplication::sharedApplication(mtm); let app_delegate = app.delegate().expect("the warp app always has a delegate"); let value: BOOL = if visible { YES } else { NO }; // `setDockIconVisible:` is a custom warp app-delegate selector. // SAFETY: messaging the app delegate on the main thread. let _: BOOL = unsafe { msg_send![&*app_delegate, setDockIconVisible: value] }; }); } fn terminate_app(&self, termination_mode: TerminationMode) { // Execute `[NSApp terminate]` asynchronously on the main thread to // ensure we don't accidentally run into any double-borrow errors. dispatch::Queue::main().exec_async(move || { // SAFETY: the closure runs on the main dispatch queue. let mtm = unsafe { MainThreadMarker::new_unchecked() }; let app = NSApplication::sharedApplication(mtm); match termination_mode { // ContentTransferred windows have already moved their content to another // window (e.g. during tab drag), so they can close immediately without // prompting the user for confirmation. TerminationMode::ForceTerminate | TerminationMode::ContentTransferred => { // `setForceTermination` is a custom warp NSApplication selector. // SAFETY: messaging the shared application. let _: () = unsafe { msg_send![&*app, setForceTermination] }; } TerminationMode::Cancellable => {} } app.terminate(None); }); } fn is_screen_reader_enabled(&self) -> Option { unsafe { Some(isVoiceOverEnabled() == YES) } } fn microphone_access_state(&self) -> MicrophoneAccessState { // SAFETY: this is a global static variable, but simply reading it should be safe. let media_type = unsafe { AVMediaTypeAudio }; let Some(media_type) = media_type else { return MicrophoneAccessState::NotDetermined; }; // SAFETY: this can raise an exception if you pass an invalid media type, but we're only // ever passing AVMediaTypeAudio here. let status = unsafe { AVCaptureDevice::authorizationStatusForMediaType(media_type) }; match status { AVAuthorizationStatus::Restricted => MicrophoneAccessState::Restricted, AVAuthorizationStatus::Denied => MicrophoneAccessState::Denied, AVAuthorizationStatus::Authorized => MicrophoneAccessState::Authorized, _ => MicrophoneAccessState::NotDetermined, // fallback } } } #[no_mangle] /// # Safety /// This function is marked unsafe because it retrieves the pointer to the callback /// function that we sent down to the Objective-C code. pub unsafe extern "C-unwind" fn warp_on_request_notification_permissions_completed( result_type: NSUInteger, result_msg: id, callback: *mut c_void, ) { let outcome = super::notification::request_permissions_outcome_from_native(result_type, result_msg); if let Ok(outcome) = outcome { let callback = Box::from_raw(callback as *mut RequestNotificationPermissionsCallback); callback(outcome); } } #[no_mangle] /// # Safety /// This function is marked unsafe because it retrieves the pointer to the callback /// function that we sent down to the Objective-C code. pub unsafe extern "C-unwind" fn warp_on_notification_send_error( error_type: NSUInteger, error_msg: id, callback: *mut c_void, ) { let notification_error = super::notification::send_error_from_native(error_type, error_msg); if let Ok(notification_error) = notification_error { let callback = Box::from_raw(callback as *mut NotificationSendErrorCallback); callback(notification_error); } } impl platform::DispatchDelegate for DispatchDelegate { fn is_main_thread(&self) -> bool { MainThreadMarker::new().is_some() } fn run_on_main_thread(&self, task: async_task::Runnable) { dispatch::Queue::main().exec_async(move || { task.run(); }); } }