Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-renderer/src/platform/mac/app.rs
1use cocoa::appkit::NSApp;
2use cocoa::foundation::{NSUInteger, NSURL};
3use cocoa::{
4 base::{id, nil},
5 foundation::{NSArray, NSAutoreleasePool, NSData, NSString},
6};
7use futures_util::future::LocalBoxFuture;
8use objc::{
9 class, msg_send,
10 runtime::{Object, Sel, BOOL, NO, YES},
11 sel, sel_impl,
12};
13 
14use std::{
15 borrow::Cow,
16 ffi::CStr,
17 os::raw::{c_char, c_void},
18 path::PathBuf,
19};
20 
21use crate::platform::{
22 app::{AppBackend, AppBuilder},
23 AsInnerMut,
24};
25use strato_ui_core::{
26 assets::AssetProvider,
27 integration::TestDriver,
28 keymap::{Keystroke, Trigger},
29 modals::{AlertDialog, ModalId},
30 platform::app::{AppCallbackDispatcher, ApproveTerminateResult},
31 platform::menu::{Menu, MenuBar},
32 platform::SaveFilePickerCallback,
33 platform::{self, FilePickerCallback},
34 AppContext, Event,
35};
36 
37use super::{
38 keycode::{Keycode, CMD_KEY, CONTROL_KEY, OPTION_KEY, SHIFT_KEY},
39 make_nsstring,
40 menus::{make_dock_menu, make_main_menu},
41 window::{get_window_state, IntegrationTestWindowManager, Window, WindowManager},
42};
43 
44pub trait NSAlert: Sized {
45 unsafe fn alloc(_: Self) -> id {
46 msg_send![class!(NSAlert), alloc]
47 }
48 
49 unsafe fn init(self) -> id;
50 unsafe fn autorelease(self) -> id;
51 unsafe fn set_message_text(self, message_text: id);
52 unsafe fn set_informative_text(self, informative_text: id);
53 unsafe fn add_button_with_title(self, title: id);
54}
55 
56impl NSAlert for id {
57 unsafe fn init(self) -> id {
58 msg_send![self, init]
59 }
60 
61 unsafe fn autorelease(self) -> id {
62 msg_send![self, autorelease]
63 }
64 
65 unsafe fn set_message_text(self, message_text: id) {
66 msg_send![self, setMessageText: message_text]
67 }
68 
69 unsafe fn set_informative_text(self, informative_text: id) {
70 msg_send![self, setInformativeText: informative_text]
71 }
72 
73 unsafe fn add_button_with_title(self, title: id) {
74 msg_send![self, addButtonWithTitle: title]
75 }
76}
77 
78pub fn create_native_platform_modal(dialog: AlertDialog) -> id {
79 unsafe {
80 let alert = NSAlert::autorelease(NSAlert::init(NSAlert::alloc(nil)));
81 alert.set_informative_text(make_nsstring(&dialog.info_text));
82 alert.set_message_text(make_nsstring(&dialog.message_text));
83 for title in dialog.buttons {
84 alert.add_button_with_title(make_nsstring(&title));
85 }
86 alert
87 }
88}
89 
90const RUST_WRAPPER_IVAR_NAME: &str = "rustWrapper";
91 
92extern "C" {
93 // Implemented in ObjC to get the warp NSApplication subclass.
94 pub(super) fn get_warp_app() -> id;
95}
96 
97/// An extension trait defining additional configurability for
98/// applications when running on macOS.
99pub trait AppExt {
100 /// Sets whether or not the application should be activated
101 /// when it is launched.
102 fn set_activate_on_launch(&mut self, value: bool);
103 
104 /// Sets the application icon which should be used when running
105 /// without an application bundle.
106 fn set_dev_icon(&mut self, value: Cow<'static, [u8]>);
107 
108 /// Sets the main menu bar constructor function.
109 fn set_menu_bar_builder(&mut self, value: impl FnOnce(&mut AppContext) -> MenuBar + 'static);
110 
111 /// Sets the macOS dock menu constructor function.
112 fn set_dock_menu_builder(&mut self, value: impl FnOnce(&mut AppContext) -> Menu + 'static);
113}
114 
115type MenuBarBuilderFn = Box<dyn FnOnce(&mut AppContext) -> MenuBar>;
116type DockMenuBuilderFn = Box<dyn FnOnce(&mut AppContext) -> Menu>;
117 
118/// The actual application, from the perspective of the platform and the
119/// main event loop. This is the true owner of all application state.
120pub struct App {
121 callbacks: AppCallbackDispatcher,
122 activate_on_launch: bool,
123 dev_icon: Option<Cow<'static, [u8]>>,
124 menu_bar_builder: Option<MenuBarBuilderFn>,
125 dock_menu_builder: Option<DockMenuBuilderFn>,
126 init_fn: Option<platform::app::AppInitCallbackFn>,
127}
128 
129impl App {
130 pub(in crate::platform) fn new(
131 callbacks: platform::app::AppCallbacks,
132 assets: Box<dyn AssetProvider>,
133 test_driver: Option<&TestDriver>,
134 ) -> Self {
135 let platform_delegate: Box<dyn platform::Delegate> = if test_driver.is_some() {
136 Box::new(
137 super::delegate::IntegrationTestDelegate::new()
138 .expect("should not fail to create platform delegate"),
139 )
140 } else {
141 Box::new(
142 super::delegate::AppDelegate::new()
143 .expect("should not fail to create platform delegate"),
144 )
145 };
146 
147 let window_manager: Box<dyn platform::WindowManager> = if test_driver.is_some() {
148 Box::new(IntegrationTestWindowManager::new())
149 } else {
150 Box::new(WindowManager::new())
151 };
152 
153 let ui_app = crate::App::new(
154 platform_delegate,
155 window_manager,
156 Box::new(super::fonts::FontDB::new()),
157 assets,
158 )
159 .expect("should not fail to construct application");
160 
161 Self {
162 callbacks: AppCallbackDispatcher::new(callbacks, ui_app),
163 activate_on_launch: true,
164 dev_icon: None,
165 menu_bar_builder: None,
166 dock_menu_builder: None,
167 init_fn: None,
168 }
169 }
170 
171 pub(in crate::platform) fn run(
172 mut self,
173 init_fn: impl FnOnce(&mut AppContext, LocalBoxFuture<'static, crate::App>) + 'static,
174 ) {
175 self.init_fn = Some(Box::new(init_fn));
176 
177 unsafe {
178 let pool = NSAutoreleasePool::new(nil);
179 
180 // Get (and create, if necessary) the underlying NSApplication.
181 let app: id = get_warp_app();
182 
183 let running_app: id = msg_send![class!(NSRunningApplication), currentApplication];
184 let bundle_id: id = msg_send![running_app, bundleIdentifier];
185 let dev_icon = if bundle_id.is_null() {
186 self.dev_icon.as_ref().map(|dev_icon| {
187 let data: id = msg_send![class!(NSData), alloc];
188 let data: id = data.initWithBytes_length_(
189 dev_icon.as_ptr() as *const c_void,
190 dev_icon.len() as u64,
191 );
192 let image: id = msg_send![class!(NSImage), alloc];
193 image.initWithData_(data)
194 })
195 } else {
196 None
197 };
198 
199 let app_delegate: id = msg_send![app, delegate];
200 
201 let self_ptr = Box::into_raw(Box::new(self));
202 (*app).set_ivar(RUST_WRAPPER_IVAR_NAME, self_ptr as *mut c_void);
203 (*app_delegate).set_ivar(RUST_WRAPPER_IVAR_NAME, self_ptr as *mut c_void);
204 
205 if let Some(dev_icon) = dev_icon {
206 let _: () = msg_send![app, setApplicationIconImage: dev_icon];
207 }
208 
209 let _: () = msg_send![app, run];
210 let _: () = msg_send![pool, drain];
211 
212 // App is done running when we get here, so we can reinstantiate the Box and drop it.
213 drop(Box::from_raw(self_ptr));
214 }
215 }
216}
217 
218impl AppExt for AppBuilder {
219 fn set_activate_on_launch(&mut self, value: bool) {
220 match self.as_inner_mut() {
221 AppBackend::CurrentPlatform(app) => app.activate_on_launch = value,
222 AppBackend::Headless(_) => (),
223 }
224 }
225 
226 fn set_dev_icon(&mut self, value: Cow<'static, [u8]>) {
227 match self.as_inner_mut() {
228 AppBackend::CurrentPlatform(app) => app.dev_icon = Some(value),
229 AppBackend::Headless(_) => (),
230 }
231 }
232 
233 fn set_menu_bar_builder(&mut self, value: impl FnOnce(&mut AppContext) -> MenuBar + 'static) {
234 match self.as_inner_mut() {
235 AppBackend::CurrentPlatform(app) => app.menu_bar_builder = Some(Box::new(value)),
236 AppBackend::Headless(_) => (),
237 }
238 }
239 
240 fn set_dock_menu_builder(&mut self, value: impl FnOnce(&mut AppContext) -> Menu + 'static) {
241 match self.as_inner_mut() {
242 AppBackend::CurrentPlatform(app) => app.dock_menu_builder = Some(Box::new(value)),
243 AppBackend::Headless(_) => (),
244 }
245 }
246}
247 
248unsafe fn get_app(object: &mut Object) -> &mut App {
249 let wrapper_ptr: *mut c_void = *object.get_ivar(RUST_WRAPPER_IVAR_NAME);
250 &mut *(wrapper_ptr as *mut App)
251}
252 
253pub(super) fn callback_dispatcher() -> &'static mut AppCallbackDispatcher {
254 unsafe {
255 let app = get_warp_app();
256 let app = get_app(&mut *app);
257 &mut app.callbacks
258 }
259}
260 
261#[no_mangle]
262pub(crate) extern "C-unwind" fn warp_app_send_global_keybinding(
263 this: &mut Object,
264 modifiers: NSUInteger,
265 key_code: NSUInteger,
266) {
267 let keystroke = {
268 let modifiers = modifiers as u16;
269 let shift_key_pressed = (modifiers & SHIFT_KEY) > 0;
270 Keycode(key_code as u16)
271 .try_to_key_name(shift_key_pressed)
272 .map(|key| Keystroke {
273 ctrl: (modifiers & CONTROL_KEY) > 0,
274 alt: (modifiers & OPTION_KEY) > 0,
275 shift: shift_key_pressed,
276 cmd: (modifiers & CMD_KEY) > 0,
277 meta: false,
278 key,
279 })
280 };
281 
282 if let Some(keystroke) = keystroke {
283 let app = unsafe { get_app(this) };
284 app.callbacks.global_shortcut_triggered(keystroke);
285 }
286}
287 
288#[no_mangle]
289pub unsafe extern "C-unwind" fn warp_app_will_finish_launching(this: &mut Object) {
290 log::info!("application will finish launching");
291 
292 let app = get_app(this);
293 
294 if app.activate_on_launch {
295 let _: () = msg_send![NSApp(), activateIgnoringOtherApps: YES];
296 }
297 
298 if let Some(init_fn) = app.init_fn.take() {
299 app.callbacks.initialize_app(init_fn);
300 }
301 
302 let app_delegate: id = msg_send![NSApp(), delegate];
303 
304 if app.callbacks.has_internet_reachability_changed_callback() {
305 let _: () = msg_send![app_delegate, setReachabilityListener];
306 }
307 
308 if let Some(menu_bar_builder) = app.menu_bar_builder.take() {
309 let menu_bar = app.callbacks.with_mutable_app_context(menu_bar_builder);
310 let nsmenu = make_main_menu(menu_bar);
311 let () = msg_send![NSApp(), setMainMenu: nsmenu];
312 }
313 
314 if let Some(dock_menu_builder) = app.dock_menu_builder.take() {
315 let dock_menu = app.callbacks.with_mutable_app_context(dock_menu_builder);
316 let nsmenu = make_dock_menu(dock_menu);
317 let _: () = msg_send![app_delegate, setDockMenu: nsmenu];
318 }
319}
320 
321#[no_mangle]
322pub(crate) extern "C-unwind" fn warp_app_did_become_active(this: &mut Object, _: Sel, _: id) {
323 let app = unsafe { get_app(this) };
324 app.callbacks.app_became_active();
325}
326 
327#[no_mangle]
328pub(crate) extern "C-unwind" fn warp_app_internet_reachability_changed(
329 this: &mut Object,
330 can_reach: u8,
331) {
332 let is_reachable = can_reach != 0;
333 
334 let app = unsafe { get_app(this) };
335 app.callbacks.internet_reachability_changed(is_reachable);
336}
337 
338/// Returns whether or not we can proceed with termination.
339#[no_mangle]
340pub(crate) extern "C-unwind" fn warp_app_should_terminate_app(this: &mut Object) -> BOOL {
341 let app = unsafe { get_app(this) };
342 
343 match app.callbacks.should_terminate_app() {
344 ApproveTerminateResult::Terminate => YES,
345 ApproveTerminateResult::Cancel => NO,
346 }
347}
348 
349/// Returns a NSAlert object if we want to show a dialog for users to confirm or
350/// nil for closing the window immediately.
351#[no_mangle]
352pub(crate) extern "C-unwind" fn warp_app_should_close_window(
353 this: &mut Object,
354 window_id: &mut Object,
355) -> BOOL {
356 let app = unsafe { get_app(this) };
357 let window = unsafe { get_window_state(window_id) };
358 
359 match app.callbacks.should_close_window(window.id()) {
360 ApproveTerminateResult::Terminate => YES,
361 ApproveTerminateResult::Cancel => NO,
362 }
363}
364 
365#[no_mangle]
366pub(crate) extern "C-unwind" fn warp_app_are_key_bindings_disabled_for_window(
367 this: &mut Object,
368 window_id: &mut Object,
369) -> BOOL {
370 let app = unsafe { get_app(this) };
371 let window = unsafe { get_window_state(window_id) };
372 
373 let disabled = app
374 .callbacks
375 .with_mutable_app_context(|ctx| !ctx.key_bindings_enabled(window.id()));
376 
377 if disabled {
378 YES
379 } else {
380 NO
381 }
382}
383 
384#[no_mangle]
385pub(crate) extern "C-unwind" fn warp_app_has_binding_for_keystroke(
386 this: &mut Object,
387 event: id,
388) -> BOOL {
389 let app = unsafe { get_app(this) };
390 let warp_event = unsafe { super::event::from_native(event, None, false) };
391 
392 let Some(Event::KeyDown { keystroke, .. }) = warp_event else {
393 return NO;
394 };
395 let has_binding = app.callbacks.with_mutable_app_context(|ctx| {
396 ctx.get_key_bindings().any(|binding| {
397 if let Trigger::Keystrokes(keystrokes) = binding.trigger {
398 keystrokes.len() == 1 && keystrokes[0] == keystroke
399 } else {
400 false
401 }
402 })
403 });
404 
405 if has_binding {
406 YES
407 } else {
408 NO
409 }
410}
411 
412#[no_mangle]
413pub(crate) extern "C-unwind" fn warp_app_has_custom_action_for_keystroke(
414 this: &mut Object,
415 event: id,
416) -> BOOL {
417 let app = unsafe { get_app(this) };
418 let warp_event = unsafe { super::event::from_native(event, None, false) };
419 
420 let Some(Event::KeyDown { keystroke, .. }) = warp_event else {
421 return NO;
422 };
423 let has_binding = app.callbacks.with_mutable_app_context(|ctx| {
424 ctx.custom_action_bindings()
425 .any(|binding| match binding.trigger {
426 Trigger::Keystrokes(keystrokes) => {
427 keystrokes.len() == 1 && keystrokes[0] == keystroke
428 }
429 Trigger::Custom(tag) => ctx
430 .default_keystroke_trigger_for_custom_action(*tag)
431 .is_some_and(|k| k == keystroke),
432 _ => false,
433 })
434 });
435 
436 if has_binding {
437 YES
438 } else {
439 NO
440 }
441}
442 
443#[no_mangle]
444pub(crate) extern "C-unwind" fn warp_app_disable_warning_modal(this: &mut Object) {
445 let app = unsafe { get_app(this) };
446 app.callbacks.warning_modal_disabled();
447}
448 
449#[no_mangle]
450pub(crate) extern "C-unwind" fn warp_app_process_modal_response(
451 this: &mut Object,
452 modal_id: ModalId,
453 response: usize,
454 disable_modal: bool,
455) {
456 let app = unsafe { get_app(this) };
457 app.callbacks
458 .process_platform_modal_response(modal_id, response, disable_modal);
459}
460 
461#[no_mangle]
462pub(crate) extern "C-unwind" fn warp_app_notification_clicked(
463 this: &mut Object,
464 date: f64,
465 data: id,
466) {
467 let app = unsafe { get_app(this) };
468 if let Ok(notification_response) =
469 unsafe { super::notification::response_from_native(date as i32, data) }
470 {
471 app.callbacks.notification_clicked(notification_response);
472 }
473}
474 
475#[no_mangle]
476extern "C-unwind" fn warp_app_did_resign_active(this: &mut Object, _: Sel, _: id) {
477 let app = unsafe { get_app(this) };
478 app.callbacks.app_resigned_active();
479}
480 
481#[no_mangle]
482extern "C-unwind" fn warp_app_will_terminate(this: &mut Object, _: Sel, _: id) {
483 let app = unsafe { get_app(this) };
484 app.callbacks.app_will_terminate();
485}
486 
487#[no_mangle]
488extern "C-unwind" fn warp_app_new_window(this: &mut Object) {
489 let app = unsafe { get_app(this) };
490 app.callbacks.open_new_window();
491}
492 
493#[no_mangle]
494extern "C-unwind" fn warp_app_active_window_changed(this: &mut Object) {
495 let app = unsafe { get_app(this) };
496 Window::close_ime_on_active_window();
497 app.callbacks
498 .active_window_changed(Window::active_window_id());
499}
500 
501#[no_mangle]
502extern "C-unwind" fn warp_app_window_did_resize(this: &mut Object) {
503 let app = unsafe { get_app(this) };
504 app.callbacks.window_resized();
505}
506 
507#[no_mangle]
508extern "C-unwind" fn warp_app_window_did_move(this: &mut Object) {
509 let app = unsafe { get_app(this) };
510 app.callbacks.window_moved();
511}
512 
513#[no_mangle]
514extern "C-unwind" fn warp_app_window_will_close(this: &mut Object, window: &mut Object) {
515 let app = unsafe { get_app(this) };
516 let window_state = unsafe { get_window_state(window) };
517 app.callbacks.window_will_close(window_state.id());
518}
519 
520#[no_mangle]
521extern "C-unwind" fn warp_app_screen_did_change(this: &mut Object) {
522 log::info!("received NSApplicationDidChangeScreenParametersNotification");
523 let app = unsafe { get_app(this) };
524 app.callbacks.screen_changed();
525}
526 
527#[no_mangle]
528extern "C-unwind" fn cpu_awakened(this: &mut Object) {
529 let app = unsafe { get_app(this) };
530 app.callbacks.cpu_awakened();
531}
532 
533#[no_mangle]
534extern "C-unwind" fn cpu_will_sleep(this: &mut Object) {
535 let app = unsafe { get_app(this) };
536 app.callbacks.cpu_will_sleep();
537}
538 
539#[no_mangle]
540extern "C-unwind" fn warp_app_open_files(this: &mut Object, paths: id) {
541 let paths = unsafe {
542 (0..paths.count())
543 .filter_map(|i| {
544 let path = paths.objectAtIndex(i);
545 match CStr::from_ptr(path.UTF8String() as *mut c_char).to_str() {
546 Ok(string) => Some(PathBuf::from(string)),
547 Err(err) => {
548 log::error!("error converting path to string: {err}");
549 None
550 }
551 }
552 })
553 .collect::<Vec<_>>()
554 };
555 let app = unsafe { get_app(this) };
556 app.callbacks.open_files(paths);
557}
558 
559#[no_mangle]
560extern "C-unwind" fn warp_app_open_urls(this: &mut Object, urls: id) {
561 let urls = unsafe {
562 (0..urls.count())
563 .filter_map(|i| {
564 let url = urls.objectAtIndex(i).absoluteString();
565 match CStr::from_ptr(url.UTF8String() as *mut c_char).to_str() {
566 Ok(string) => Some(string.to_string()),
567 Err(err) => {
568 log::error!("error converting url to string: {err}");
569 None
570 }
571 }
572 })
573 .collect::<Vec<_>>()
574 };
575 
576 let app = unsafe { get_app(this) };
577 app.callbacks.open_urls(urls);
578}
579 
580#[no_mangle]
581extern "C-unwind" fn warp_app_os_appearance_changed(this: &mut Object) {
582 let app = unsafe { get_app(this) };
583 app.callbacks.os_appearance_changed();
584}
585 
586// Calls the callback with None if no file was selected
587#[no_mangle]
588pub(crate) extern "C-unwind" fn warp_open_panel_file_selected(urls: id, callback: *mut c_void) {
589 // Start by converting the callback from a raw pointer back into a Box, to
590 // avoid the memory leak that would occur if we left it in raw pointer form.
591 let callback = unsafe { Box::from_raw(callback as *mut FilePickerCallback) };
592 
593 let paths = unsafe {
594 (0..urls.count())
595 .map(|i| {
596 let file_url = urls.objectAtIndex(i);
597 let file_path: id = msg_send![file_url, path];
598 let slice = std::slice::from_raw_parts(
599 file_path.UTF8String() as *const std::ffi::c_uchar,
600 file_path.len(),
601 );
602 std::str::from_utf8_unchecked(slice).to_string()
603 })
604 .collect::<Vec<_>>()
605 };
606 
607 if paths.is_empty() {
608 log::info!("No file was selected. Dialog was cancelled.")
609 }
610 
611 let app = unsafe { get_app(&mut *get_warp_app()) };
612 app.callbacks.with_mutable_app_context(move |ctx| {
613 callback(Ok(paths), ctx);
614 });
615}
616 
617// Calls the save callback with the selected path or None if cancelled
618#[no_mangle]
619pub(crate) extern "C-unwind" fn warp_save_panel_file_selected(url: id, callback: *mut c_void) {
620 let callback = unsafe { Box::from_raw(callback as *mut SaveFilePickerCallback) };
621 
622 let path = if url.is_null() {
623 None
624 } else {
625 unsafe {
626 let file_path: id = msg_send![url, path];
627 let slice = std::slice::from_raw_parts(
628 file_path.UTF8String() as *const std::ffi::c_uchar,
629 file_path.len(),
630 );
631 Some(std::str::from_utf8_unchecked(slice).to_string())
632 }
633 };
634 
635 if path.is_none() {
636 log::info!("Save dialog was cancelled.");
637 }
638 
639 let app = unsafe { get_app(&mut *get_warp_app()) };
640 app.callbacks.with_mutable_app_context(move |ctx| {
641 callback(path, ctx);
642 });
643}
644