Files
galaxy/crates/galaxyui/src/platform/mac/delegate.rs
T

503 lines
17 KiB
Rust

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<dyn FnOnce(RequestPermissionsOutcome) + Send>;
type NotificationSendErrorCallback = Box<dyn FnOnce(NotificationSendError) + Send>;
/// Delegator that wraps platform-specific calls in a common API.
pub struct AppDelegate {
clipboard: Clipboard,
dispatch_delegate: Arc<DispatchDelegate>,
}
pub struct IntegrationTestDelegate {
app_delegate: AppDelegate,
clipboard: InMemoryClipboard,
}
impl IntegrationTestDelegate {
pub fn new() -> Result<Self> {
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<ApplicationBundleInfo<'_>> {
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<dyn platform::DispatchDelegate> {
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<bool> {
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<Self> {
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<ApplicationBundleInfo<'_>> {
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<dyn platform::DispatchDelegate> {
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::<NSApplication>();
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<bool> {
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();
});
}
}