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/objc/app.m
1#import <AppKit/AppKit.h>
2#import <Carbon/Carbon.h>
3#import <ServiceManagement/ServiceManagement.h>
4#import <UserNotifications/UserNotifications.h>
5 
6#import "alert.h"
7#import "app.h"
8#import "host_view.h"
9#import "hotkey.h"
10#import "menus.h"
11 
12#import "reachability.h"
13 
14static void *NSAppThemeChangeContext = &NSAppThemeChangeContext;
15 
16NSMutableDictionary<NSNumber *, WarpHotKey *> *_hotKeys;
17UInt32 _nextHotKeyID;
18 
19OSStatus HotkeyPressedHandler(EventHandlerCallRef _inCaller __unused, EventRef inEvent,
20 void *inUserData);
21OSStatus HotkeyPressedHandler(EventHandlerCallRef _inCaller __unused, EventRef inEvent,
22 void *inUserData) {
23 EventHotKeyID hotKeyID;
24 
25 // Get the hotKeyID corresponding to the pressed hot key.
26 if (GetEventParameter(inEvent, kEventParamDirectObject, typeEventHotKeyID, nil,
27 sizeof(EventHotKeyID), nil, &hotKeyID)) {
28 return eventNotHandledErr;
29 }
30 
31 WarpHotKey *hotkey = _hotKeys[@(hotKeyID.id)];
32 if (hotkey) {
33 warp_app_send_global_keybinding((NSApplication *)inUserData, hotkey->_modifierKeys,
34 hotkey->_keyCode);
35 return noErr;
36 }
37 
38 return eventNotHandledErr;
39}
40 
41BOOL isDarkMode() {
42 NSAppearanceName name = [NSApp.effectiveAppearance
43 bestMatchFromAppearancesWithNames:@[ NSAppearanceNameAqua, NSAppearanceNameDarkAqua ]];
44 return name == NSAppearanceNameDarkAqua;
45}
46 
47NSArray *getFilePathsFromPasteboard() {
48 NSPasteboard *pb = [NSPasteboard generalPasteboard];
49 NSArray *types = [pb types];
50 
51 if ([types containsObject:NSPasteboardTypeFileURL]) {
52 return [pb getFilePaths];
53 }
54 
55 return [NSArray array];
56}
57 
58void *registerGlobalHotkey(NSUInteger key, NSUInteger modifiers) {
59 EventHotKeyRef hotKeyRef = NULL;
60 EventHotKeyID hotKeyID = {0, _nextHotKeyID};
61 if (RegisterEventHotKey((UInt32)key, (UInt32)modifiers, hotKeyID, GetEventDispatcherTarget(), 0,
62 &hotKeyRef)) {
63 return nil;
64 };
65 [_hotKeys setObject:[[[WarpHotKey alloc] initWithEventHotKey:hotKeyRef
66 keyCode:key
67 modifierKeys:modifiers] autorelease]
68 forKey:@(hotKeyID.id)];
69 _nextHotKeyID++;
70 return nil;
71}
72 
73void *unregisterGlobalHotkey(NSUInteger key, NSUInteger modifiers) {
74 NSNumber *keyIdx;
75 BOOL found = NO;
76 
77 for (NSNumber *hotKeyID in _hotKeys) {
78 if ([[_hotKeys objectForKey:hotKeyID] hotKeyKeyAndModifierEquals:key
79 modifierKeys:modifiers]) {
80 keyIdx = hotKeyID;
81 found = YES;
82 break;
83 }
84 }
85 
86 if (found) {
87 UnregisterEventHotKey([_hotKeys objectForKey:keyIdx]->_eventHotKey);
88 [_hotKeys removeObjectForKey:keyIdx];
89 }
90 return nil;
91}
92 
93NSRect screenFrame() { return [[NSScreen mainScreen] frame]; }
94 
95NSUInteger activeScreenId() {
96 return [[[[NSScreen mainScreen] deviceDescription] objectForKey:@"NSScreenNumber"]
97 unsignedIntegerValue];
98}
99 
100@interface WarpMenuItemDelegate : NSObject <NSMenuDelegate> {
101 // Rust expects an ivar with this name.
102 void *rustWrapper;
103}
104@end
105 
106@implementation WarpDelegate {
107 // Rust expects an ivar with this name.
108 void *rustWrapper;
109 
110 // Whether we have a pending active window change notification.
111 BOOL hasPendingActiveWindowChange;
112 
113 // Internet reachability.
114 Reachability *internetReachable;
115 
116 // Track the current reachable state so we don't double fire reachability state
117 // changed events.
118 NSNumber *isReachable;
119 
120 // Whether we should force termination.
121 BOOL forceTermination;
122 
123 // Whether we should terminate the application upon the app
124 // being hidden. This allows us to hide the app before running any
125 // slower termination logic.
126 BOOL terminateOnHide;
127}
128 
129- (id)init {
130 [super init];
131 NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter];
132 [defaultCenter addObserver:self
133 selector:@selector(keyWindowChanged:)
134 name:NSWindowDidBecomeKeyNotification
135 object:nil];
136 [defaultCenter addObserver:self
137 selector:@selector(keyWindowChanged:)
138 name:NSWindowDidResignKeyNotification
139 object:nil];
140 
141 [defaultCenter addObserver:self
142 selector:@selector(windowMoved:)
143 name:NSWindowDidMoveNotification
144 object:nil];
145 [defaultCenter addObserver:self
146 selector:@selector(windowResized:)
147 name:NSWindowDidResizeNotification
148 object:nil];
149 [defaultCenter addObserver:self
150 selector:@selector(screenChanged:)
151 name:NSApplicationDidChangeScreenParametersNotification
152 object:nil];
153 
154 // For the following notifications, we need to register them on the workspace
155 // notification center, which is different from the default NSNotificationCenter.
156 // See here for more details: https://developer.apple.com/library/archive/qa/qa1340/_index.html.
157 NSNotificationCenter *workspaceCenter = [[NSWorkspace sharedWorkspace] notificationCenter];
158 [workspaceCenter addObserver:self
159 selector:@selector(cpuAwakened:)
160 name:NSWorkspaceDidWakeNotification
161 object:nil];
162 [workspaceCenter addObserver:self
163 selector:@selector(cpuWillSleep:)
164 name:NSWorkspaceWillSleepNotification
165 object:nil];
166 
167 // Tell the shared notification center to use the current view as the
168 // `UNUserNotificationCenterDelegate` delegate. We only do this if the application
169 // is bundled, otherwise the app will crash when trying to set the delegate. This allows
170 // strato_ui to still be run via `cargo run` since the app is not bundled in this case. Note this
171 // has no functional change in the non-bundled case since the app must be bundled for
172 // notifications to actually be sent/received.
173 NSString *bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier];
174 if (bundleIdentifier != nil && ![bundleIdentifier isEqualToString:(@"")]) {
175 UNUserNotificationCenter *user_notification_center =
176 [UNUserNotificationCenter currentNotificationCenter];
177 user_notification_center.delegate = self;
178 
179 // Create and register the notification category.
180 UNNotificationCategory *CustomizedNotification = [UNNotificationCategory
181 categoryWithIdentifier:@"CUSTOMIZED_NOTIFICATION"
182 actions:@[]
183 intentIdentifiers:@[]
184 options:UNNotificationCategoryOptionCustomDismissAction];
185 
186 [user_notification_center
187 setNotificationCategories:[NSSet setWithObjects:CustomizedNotification, nil]];
188 }
189 
190 // Initiate the global hotkey handlers first so we could register them at the rust
191 // side callback.
192 EventTypeSpec eventType = {kEventClassKeyboard, kEventHotKeyPressed};
193 InstallApplicationEventHandler(HotkeyPressedHandler, 1, &eventType, self, NULL);
194 _hotKeys = [[NSMutableDictionary alloc] init];
195 
196 return self;
197}
198 
199- (void)dealloc {
200 [NSApp removeObserver:self forKeyPath:@"effectiveAppearance" context:NSAppThemeChangeContext];
201 [[NSNotificationCenter defaultCenter] removeObserver:self];
202 [internetReachable stopNotifier];
203 [internetReachable release];
204 [super dealloc];
205}
206 
207- (void)applicationWillFinishLaunching:(NSNotification *)note {
208 // On macOS 26, the autofill heuristic controller causes significant slowdowns.
209 // It's not clear why, but other apps which use custom text inputs have reported
210 // the same issue. See:
211 // * Ghostty: https://github.com/ghostty-org/ghostty/pull/8625
212 // * Zed: https://github.com/zed-industries/zed/issues/33182
213 // * Twitter thread discussing the issue: https://x.com/mitchellh/status/1967324131801915875
214 NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
215 [defaults setBool:NO forKey:@"NSAutoFillHeuristicControllerEnabled"];
216 
217 if (rustWrapper) warp_app_will_finish_launching(note.object);
218}
219 
220- (void)applicationDidFinishLaunching:(NSNotification *)note {
221 [NSApp addObserver:self
222 forKeyPath:@"effectiveAppearance"
223 options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
224 context:NSAppThemeChangeContext];
225}
226 
227- (void)observeValueForKeyPath:(NSString *)keyPath
228 ofObject:(id)object
229 change:(NSDictionary *)change
230 context:(void *)context {
231 if (context == NSAppThemeChangeContext) {
232 if (rustWrapper) warp_app_os_appearance_changed(self);
233 } else {
234 // Any unrecognized context must belong to super
235 [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
236 }
237}
238 
239- (void)applicationDidBecomeActive:(NSNotification *)note {
240 if (rustWrapper) warp_app_did_become_active(note.object);
241}
242 
243- (void)setForceTermination {
244 forceTermination = YES;
245}
246 
247// Unfullscreens any windows that are currently fullscreen.
248- (void)unfullscreenAllWindows:(NSApplication *)application {
249 for (NSWindow *window in [application windows]) {
250 if ((window.styleMask & NSWindowStyleMaskFullScreen) == NSWindowStyleMaskFullScreen) {
251 [window toggleFullScreen:nil];
252 return;
253 }
254 }
255}
256 
257- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)application {
258 BOOL okToTerminate = YES;
259 
260 // If this is the second termination attempt after we've already hidden the app, we can go ahead
261 // and terminate.
262 if (terminateOnHide) {
263 return NSTerminateNow;
264 }
265 
266 if (!forceTermination) {
267 // Make sure the rust app doesn't have any reasons to interrupt quit, e.g. needs to relaunch
268 // for autoupdate, but launching the new process failed.
269 okToTerminate = warp_app_should_terminate_app(application);
270 }
271 
272 if (okToTerminate) {
273 // We want to hide the application before we start the teardown
274 // process, to ensure the user isn't affected by any slow teardown
275 // steps. The tricky part here is that a call to `[NSApp hide]` isn't
276 // handled synchronously, it is processed on the event loop.
277 //
278 // To work around this, we enqueue the hide on the event loop and set
279 // some state so we know to resume termination of the application when
280 // the hide takes effect. As a fallback, if we never get notified that
281 // the application was hidden, we always resume termination after 5s.
282 // We deliberately do _not_ return `NSTerminateLater` here because it enables a special mode
283 // of the event loop that is meant specifically for handling modals. We also make sure to
284 // first exit any fullscreen windows before we hide--`NSApplication#hide` is a NOOP if there
285 // are any full screen windows.
286 
287 [self unfullscreenAllWindows:application];
288 [application hide:nil];
289 terminateOnHide = YES;
290 
291 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
292 dispatch_get_main_queue(), ^{
293 [application terminate:nil];
294 });
295 }
296 return NSTerminateCancel;
297}
298 
299- (void)applicationDidHide:(NSNotification *)note {
300 if (terminateOnHide) {
301 NSApplication *app = note.object;
302 [app terminate:nil];
303 }
304}
305 
306- (void)applicationDidResignActive:(NSNotification *)note {
307 if (rustWrapper) warp_app_did_resign_active(note.object);
308}
309 
310- (void)applicationWillTerminate:(NSNotification *)note {
311 if (rustWrapper) warp_app_will_terminate(note.object);
312}
313 
314- (void)application:(NSApplication *)sender openFiles:(NSArray<NSString *> *)filenames {
315 if (rustWrapper) warp_app_open_files(sender, filenames);
316}
317 
318- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
319 if (rustWrapper) warp_app_open_urls(application, urls);
320}
321 
322// This is called when clicking on the app in the Dock or from Finder.
323// If there's no visible windows, we will open one.
324- (BOOL)applicationShouldHandleReopen:(NSApplication *)app hasVisibleWindows:(BOOL)flag {
325 if (rustWrapper && !flag) {
326 warp_app_new_window(app);
327 return NO; // do nothing
328 }
329 return YES;
330}
331 
332- (void)keyWindowChanged:(NSNotification *)note {
333 // We use an async dispatch here for two reasons:
334 // 1. When the active window changes, this will be called twice (once for resign, once for
335 // activated). We can coalesce these calls.
336 // 2. When a new window is created, warp will activate it; if we recursively call back into
337 // warp then we will cause the app to be mutably borrowed while already borrowed.
338 if (!hasPendingActiveWindowChange) {
339 hasPendingActiveWindowChange = YES;
340 dispatch_async(dispatch_get_main_queue(), ^{
341 self->hasPendingActiveWindowChange = NO;
342 if (self->rustWrapper) warp_app_active_window_changed(self);
343 });
344 }
345}
346 
347- (void)windowMoved:(NSNotification *)note {
348 // We need to use async dispatch here because the event loop in appkit calls the
349 // app notification first before calling the window notification. Since we are updating
350 // the window properties within the window notification, we need to make sure this
351 // callback gets triggered after the window notification. Thus using the async dispatch
352 // here ensures we always save the most up-to-date value within the database.
353 dispatch_async(dispatch_get_main_queue(), ^{
354 if (self->rustWrapper) warp_app_window_did_move(self);
355 });
356}
357 
358- (void)windowResized:(NSNotification *)note {
359 dispatch_async(dispatch_get_main_queue(), ^{
360 if (self->rustWrapper) warp_app_window_did_resize(self);
361 });
362}
363 
364- (void)screenChanged:(NSNotification *)note {
365 dispatch_async(dispatch_get_main_queue(), ^{
366 if (self->rustWrapper) warp_app_screen_did_change(self);
367 });
368}
369 
370- (void)cpuAwakened:(NSNotification *)note {
371 dispatch_async(dispatch_get_main_queue(), ^{
372 if (self->rustWrapper) cpu_awakened(self);
373 });
374}
375 
376- (void)cpuWillSleep:(NSNotification *)note {
377 dispatch_async(dispatch_get_main_queue(), ^{
378 if (self->rustWrapper) cpu_will_sleep(self);
379 });
380}
381 
382- (void)menuNeedsUpdate:(NSMenu *)menu {
383 // Trigger warp_menu_item_needs_update for every item with our class set as its represented
384 // object.
385 Class warpHandlerClass = [WarpCustomMenuItemHandler class];
386 for (NSMenuItem *item in menu.itemArray) {
387 id obj = item.representedObject;
388 if ([obj isKindOfClass:warpHandlerClass]) {
389 [obj itemNeedsUpdate:item];
390 }
391 }
392}
393 
394- (void)setReachabilityListener {
395 internetReachable = [[Reachability reachabilityWithHostname:@"0.0.0.0"] retain];
396 
397 // Internet is reachable.
398 internetReachable.reachableBlock = ^(Reachability *reach __unused) {
399 // Update the UI on the main thread.
400 dispatch_async(dispatch_get_main_queue(), ^{
401 if (self->isReachable == nil || [self->isReachable intValue] == 0) {
402 self->isReachable = [NSNumber numberWithBool:YES];
403 if (self->rustWrapper) warp_app_internet_reachability_changed(self, YES);
404 }
405 });
406 };
407 
408 // Internet is not reachable.
409 internetReachable.unreachableBlock = ^(Reachability *reach __unused) {
410 // Update the UI on the main thread.
411 dispatch_async(dispatch_get_main_queue(), ^{
412 if (self->isReachable == nil || [self->isReachable intValue] > 0) {
413 self->isReachable = [NSNumber numberWithBool:NO];
414 if (self->rustWrapper) warp_app_internet_reachability_changed(self, NO);
415 }
416 });
417 };
418 
419 // Dispatch an initial call to check internet reachability so app could get notified
420 // of the reachability status it starts in.
421 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
422 BOOL internetIsReachable = [internetReachable isReachable];
423 dispatch_async(dispatch_get_main_queue(), ^{
424 if (self->isReachable == nil) {
425 self->isReachable = [NSNumber numberWithBool:internetIsReachable];
426 if (self->rustWrapper)
427 warp_app_internet_reachability_changed(self, internetIsReachable);
428 }
429 });
430 });
431 
432 [internetReachable startNotifier];
433}
434 
435// Returns a new NSMenu in the mac dock. Gets called every time we pull up the dock menu
436- (NSMenu *)applicationDockMenu:(NSApplication *)sender {
437 return self.dockMenu;
438}
439 
440- (void)userNotificationCenter:(UNUserNotificationCenter *)center
441 didReceiveNotificationResponse:(UNNotificationResponse *)response
442 withCompletionHandler:(void (^)(void))completionHandler {
443 // Handle what happens when the user clicks the notification. Warp doesn't support any actions
444 // other than the default action currently.
445 if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
446 NSDictionary *userInfo = response.notification.request.content.userInfo;
447 NSString *data = userInfo[@"DATA"];
448 
449 if (rustWrapper) {
450 warp_app_notification_clicked(self, response.notification.date.timeIntervalSince1970,
451 data);
452 }
453 }
454}
455 
456@end
457 
458@implementation WarpApplication {
459 // Rust expects an ivar with this name.
460 void *rustWrapper;
461}
462 
463- (void)setForceTermination {
464 WarpDelegate *delegate = (WarpDelegate *)self.delegate;
465 [delegate setForceTermination];
466}
467 
468- (void)showModal:(NSAlert *)alert modalId:(NSUInteger)modalId {
469 dispatch_async(dispatch_get_main_queue(), ^{
470 NSModalResponse response = configureAndRunModal(alert, self);
471 
472 BOOL disable_modal = alert.suppressionButton.state == NSControlStateValueOn;
473 // Subtracting `NSAlertFirstButtonReturn` from `response` yields the 0-based index of the
474 // button that was actually clicked.
475 warp_app_process_modal_response(self, modalId, response - NSAlertFirstButtonReturn,
476 disable_modal);
477 });
478}
479 
480@end
481 
482WarpApplication *get_warp_app() {
483 // Set up the delegate (once).
484 // The delegate is deliberately leaked.
485 WarpApplication *app = [WarpApplication sharedApplication];
486 static dispatch_once_t once;
487 static id sharedDelegate;
488 dispatch_once(&once, ^{
489 sharedDelegate = [[WarpDelegate alloc] init];
490 [app setDelegate:sharedDelegate];
491 
492 // Hack to work around the fact that warp is frequently tested as a
493 // standalone (unbundled) binary.
494 app.activationPolicy = NSApplicationActivationPolicyRegular;
495 });
496 return app;
497}
498 
499// \return an empty NSMenu with the given title, setting up the delegate appropriately.
500// The result is autoreleased.
501NSMenu *make_delegated_menu(NSString *title) {
502 NSMenu *result = [[[NSMenu alloc] initWithTitle:title] autorelease];
503 result.delegate = (WarpDelegate *)[[WarpApplication sharedApplication] delegate];
504 return result;
505}
506 
507// Create Services, a system-defined standard menu on macOS
508// The result is autoreleased.
509NSMenuItem *make_services_menu_item() {
510 // Create the services menu. `servicesMenu` retains, so autorelease our +1 ownership.
511 NSApp.servicesMenu = [[[NSMenu alloc] init] autorelease];
512 
513 // Create menu item for it
514 NSMenuItem *servicesItem = [[[NSMenuItem alloc] init] autorelease];
515 servicesItem.title = @"Services";
516 servicesItem.submenu = NSApp.servicesMenu;
517 
518 return servicesItem;
519}
520 
521// \return a new menu item that wraps the given context pointer.
522// The pointer will be provided back to Warp in the callbacks (see menus.h).
523// The result is autoreleased.
524NSMenuItem *make_warp_custom_menu_item(void *context) {
525 WarpCustomMenuItemHandler *handler =
526 [[[WarpCustomMenuItemHandler alloc] initWithContext:context] autorelease];
527 
528 // Sets action to NULL if menu item has submenu, so the menu doesn't close when item is clicked
529 NSMenuItem *item = [[[NSMenuItem alloc] initWithTitle:@""
530 action:@selector(itemWasTriggered:)
531 keyEquivalent:@""] autorelease];
532 item.representedObject = handler;
533 item.target = handler;
534 return item;
535}
536 
537NSString *executableInApplicationBundleWithIdentifier(NSString *bundle_path) {
538 NSBundle *bundle = [NSBundle bundleWithPath:bundle_path];
539 NSString *executable = [bundle.bundlePath stringByAppendingPathComponent:@"Contents/MacOS"];
540 executable = [executable
541 stringByAppendingPathComponent:[bundle
542 objectForInfoDictionaryKey:(id)kCFBundleExecutableKey]];
543 return executable;
544}
545 
546NSString *absolutePathForApplicationBundleWithIdentifier(NSString *bundle_identifier) {
547 NSURL *url =
548 [[NSWorkspace sharedWorkspace] URLForApplicationWithBundleIdentifier:bundle_identifier];
549 return url.path;
550}
551 
552BOOL isVoiceOverEnabled() { return [[NSWorkspace sharedWorkspace] isVoiceOverEnabled]; }
553