| 1 | //go:build !ios |
| 2 | |
| 3 | #import <Cocoa/Cocoa.h> |
| 4 | #include "systray.h" |
| 5 | |
| 6 | #if __MAC_OS_X_VERSION_MIN_REQUIRED < 101400 |
| 7 | |
| 8 | #ifndef NSControlStateValueOff |
| 9 | #define NSControlStateValueOff NSOffState |
| 10 | #endif |
| 11 | |
| 12 | #ifndef NSControlStateValueOn |
| 13 | #define NSControlStateValueOn NSOnState |
| 14 | #endif |
| 15 | |
| 16 | #endif |
| 17 | |
| 18 | // The modifier bits used by the Go API, converted to NSEventModifierFlags below. |
| 19 | #define SYSTRAY_MOD_SHIFT (1 << 0) |
| 20 | #define SYSTRAY_MOD_CONTROL (1 << 1) |
| 21 | #define SYSTRAY_MOD_ALT (1 << 2) |
| 22 | #define SYSTRAY_MOD_SUPER (1 << 3) |
| 23 | |
| 24 | @interface MenuItem : NSObject |
| 25 | { |
| 26 | @public |
| 27 | NSNumber* menuId; |
| 28 | NSNumber* parentMenuId; |
| 29 | NSString* title; |
| 30 | NSString* tooltip; |
| 31 | NSString* shortcutKey; |
| 32 | NSEventModifierFlags shortcutMods; |
| 33 | short disabled; |
| 34 | short checked; |
| 35 | } |
| 36 | -(id) initWithId: (int)theMenuId |
| 37 | withParentMenuId: (int)theParentMenuId |
| 38 | withTitle: (const char*)theTitle |
| 39 | withTooltip: (const char*)theTooltip |
| 40 | withShortcutKey: (const char*)theShortcutKey |
| 41 | withShortcutMods: (unsigned int)theShortcutMods |
| 42 | withDisabled: (short)theDisabled |
| 43 | withChecked: (short)theChecked; |
| 44 | @end |
| 45 | @implementation MenuItem |
| 46 | -(id) initWithId: (int)theMenuId |
| 47 | withParentMenuId: (int)theParentMenuId |
| 48 | withTitle: (const char*)theTitle |
| 49 | withTooltip: (const char*)theTooltip |
| 50 | withShortcutKey: (const char*)theShortcutKey |
| 51 | withShortcutMods: (unsigned int)theShortcutMods |
| 52 | withDisabled: (short)theDisabled |
| 53 | withChecked: (short)theChecked |
| 54 | { |
| 55 | menuId = [NSNumber numberWithInt:theMenuId]; |
| 56 | parentMenuId = [NSNumber numberWithInt:theParentMenuId]; |
| 57 | title = [[NSString alloc] initWithCString:theTitle |
| 58 | encoding:NSUTF8StringEncoding]; |
| 59 | tooltip = [[NSString alloc] initWithCString:theTooltip |
| 60 | encoding:NSUTF8StringEncoding]; |
| 61 | shortcutKey = [[NSString alloc] initWithCString:theShortcutKey |
| 62 | encoding:NSUTF8StringEncoding]; |
| 63 | shortcutMods = 0; |
| 64 | if (theShortcutMods & SYSTRAY_MOD_SHIFT) { |
| 65 | shortcutMods |= NSEventModifierFlagShift; |
| 66 | } |
| 67 | if (theShortcutMods & SYSTRAY_MOD_CONTROL) { |
| 68 | shortcutMods |= NSEventModifierFlagControl; |
| 69 | } |
| 70 | if (theShortcutMods & SYSTRAY_MOD_ALT) { |
| 71 | shortcutMods |= NSEventModifierFlagOption; |
| 72 | } |
| 73 | if (theShortcutMods & SYSTRAY_MOD_SUPER) { |
| 74 | shortcutMods |= NSEventModifierFlagCommand; |
| 75 | } |
| 76 | disabled = theDisabled; |
| 77 | checked = theChecked; |
| 78 | return self; |
| 79 | } |
| 80 | @end |
| 81 | |
| 82 | @interface RightClickDetector : NSView |
| 83 | |
| 84 | @property (copy) void (^onRightClicked)(NSEvent *); |
| 85 | |
| 86 | @end |
| 87 | |
| 88 | @implementation RightClickDetector |
| 89 | |
| 90 | - (void)rightMouseUp:(NSEvent *)theEvent { |
| 91 | if (!self.onRightClicked) { |
| 92 | return; |
| 93 | } |
| 94 | |
| 95 | self.onRightClicked(theEvent); |
| 96 | } |
| 97 | |
| 98 | @end |
| 99 | |
| 100 | |
| 101 | @interface SystrayAppDelegate: NSObject <NSApplicationDelegate, NSMenuDelegate> |
| 102 | - (void) add_or_update_menu_item:(MenuItem*) item; |
| 103 | - (IBAction)menuHandler:(id)sender; |
| 104 | - (void)menuWillOpen:(NSMenu*)menu; |
| 105 | @property (assign) IBOutlet NSWindow *window; |
| 106 | @end |
| 107 | |
| 108 | @implementation SystrayAppDelegate |
| 109 | { |
| 110 | NSStatusItem *statusItem; |
| 111 | NSMenu *menu; |
| 112 | NSCondition* cond; |
| 113 | } |
| 114 | |
| 115 | @synthesize window = _window; |
| 116 | |
| 117 | - (void)applicationDidFinishLaunching:(NSNotification *)aNotification |
| 118 | { |
| 119 | self->statusItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength]; |
| 120 | |
| 121 | self->menu = [[NSMenu alloc] init]; |
| 122 | self->menu.delegate = self; |
| 123 | self->menu.autoenablesItems = FALSE; |
| 124 | // Once the user has removed it, the item needs to be explicitly brought back, |
| 125 | // even restarting the application is insufficient. |
| 126 | // Since the interface from Go is relatively simple, for now we ensure it's |
| 127 | // always visible at application startup. |
| 128 | self->statusItem.visible = TRUE; |
| 129 | |
| 130 | NSStatusBarButton *button = self->statusItem.button; |
| 131 | button.action = @selector(leftMouseClicked); |
| 132 | |
| 133 | [NSEvent addLocalMonitorForEventsMatchingMask: (NSEventTypeLeftMouseDown|NSEventTypeRightMouseDown) |
| 134 | handler: ^NSEvent *(NSEvent *event) { |
| 135 | if (event.window != self->statusItem.button.window) { |
| 136 | return event; |
| 137 | } |
| 138 | |
| 139 | if (event.modifierFlags & NSEventModifierFlagCommand) { |
| 140 | return event; |
| 141 | } |
| 142 | |
| 143 | [self leftMouseClicked]; |
| 144 | |
| 145 | return nil; |
| 146 | }]; |
| 147 | |
| 148 | NSSize size = [button frame].size; |
| 149 | NSRect frame = CGRectMake(0, 0, size.width, size.height); |
| 150 | RightClickDetector *rightClicker = [[RightClickDetector alloc] initWithFrame:frame]; |
| 151 | rightClicker.onRightClicked = ^(NSEvent *event) { |
| 152 | [self rightMouseClicked]; |
| 153 | }; |
| 154 | |
| 155 | rightClicker.autoresizingMask = (NSViewWidthSizable | |
| 156 | NSViewHeightSizable); |
| 157 | button.autoresizesSubviews = YES; |
| 158 | [button addSubview:rightClicker]; |
| 159 | |
| 160 | systray_ready(); |
| 161 | } |
| 162 | |
| 163 | - (void)rightMouseClicked { |
| 164 | systray_right_click(); |
| 165 | } |
| 166 | |
| 167 | - (void)leftMouseClicked { |
| 168 | systray_left_click(); |
| 169 | } |
| 170 | |
| 171 | - (void)applicationWillTerminate:(NSNotification *)aNotification |
| 172 | { |
| 173 | systray_on_exit(); |
| 174 | } |
| 175 | |
| 176 | - (void)setRemovalAllowed { |
| 177 | NSStatusItemBehavior behavior = [self->statusItem behavior]; |
| 178 | behavior |= NSStatusItemBehaviorRemovalAllowed; |
| 179 | self->statusItem.behavior = behavior; |
| 180 | } |
| 181 | |
| 182 | - (void)setRemovalForbidden { |
| 183 | NSStatusItemBehavior behavior = [self->statusItem behavior]; |
| 184 | behavior &= ~NSStatusItemBehaviorRemovalAllowed; |
| 185 | // Ensure the menu item is visible if it was removed, since we're now |
| 186 | // disallowing removal. |
| 187 | self->statusItem.visible = TRUE; |
| 188 | self->statusItem.behavior = behavior; |
| 189 | } |
| 190 | |
| 191 | - (void)setIcon:(NSImage *)image { |
| 192 | statusItem.button.image = image; |
| 193 | [self updateTitleButtonStyle]; |
| 194 | } |
| 195 | |
| 196 | - (void)setTitle:(NSString *)title { |
| 197 | statusItem.button.title = title; |
| 198 | [self updateTitleButtonStyle]; |
| 199 | } |
| 200 | |
| 201 | - (void)updateTitleButtonStyle { |
| 202 | if (statusItem.button.image != nil) { |
| 203 | if ([statusItem.button.title length] == 0) { |
| 204 | statusItem.button.imagePosition = NSImageOnly; |
| 205 | } else { |
| 206 | statusItem.button.imagePosition = NSImageLeft; |
| 207 | } |
| 208 | } else { |
| 209 | statusItem.button.imagePosition = NSNoImage; |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | |
| 214 | - (void)setTooltip:(NSString *)tooltip { |
| 215 | statusItem.button.toolTip = tooltip; |
| 216 | } |
| 217 | |
| 218 | - (IBAction)menuHandler:(id)sender |
| 219 | { |
| 220 | NSNumber* menuId = [sender representedObject]; |
| 221 | systray_menu_item_selected(menuId.intValue); |
| 222 | } |
| 223 | |
| 224 | - (void)menuWillOpen:(NSMenu *)menu { |
| 225 | systray_menu_will_open(); |
| 226 | } |
| 227 | |
| 228 | - (void)add_or_update_menu_item:(MenuItem *)item { |
| 229 | NSMenu *theMenu = self->menu; |
| 230 | NSMenuItem *parentItem; |
| 231 | if ([item->parentMenuId integerValue] > 0) { |
| 232 | parentItem = find_menu_item(menu, item->parentMenuId); |
| 233 | if (parentItem.hasSubmenu) { |
| 234 | theMenu = parentItem.submenu; |
| 235 | } else { |
| 236 | theMenu = [[NSMenu alloc] init]; |
| 237 | [theMenu setAutoenablesItems:NO]; |
| 238 | [parentItem setSubmenu:theMenu]; |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | NSMenuItem *menuItem = find_menu_item(theMenu, item->menuId); |
| 243 | if (menuItem == NULL) { |
| 244 | menuItem = [theMenu addItemWithTitle:item->title |
| 245 | action:@selector(menuHandler:) |
| 246 | keyEquivalent:@""]; |
| 247 | [menuItem setRepresentedObject:item->menuId]; |
| 248 | } |
| 249 | [menuItem setTitle:item->title]; |
| 250 | [menuItem setTag:[item->menuId integerValue]]; |
| 251 | [menuItem setTarget:self]; |
| 252 | [menuItem setToolTip:item->tooltip]; |
| 253 | [menuItem setKeyEquivalent:item->shortcutKey]; |
| 254 | [menuItem setKeyEquivalentModifierMask:item->shortcutMods]; |
| 255 | if (item->disabled == 1) { |
| 256 | menuItem.enabled = FALSE; |
| 257 | } else { |
| 258 | menuItem.enabled = TRUE; |
| 259 | } |
| 260 | if (item->checked == 1) { |
| 261 | menuItem.state = NSControlStateValueOn; |
| 262 | } else { |
| 263 | menuItem.state = NSControlStateValueOff; |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | NSMenuItem *find_menu_item(NSMenu *ourMenu, NSNumber *menuId) { |
| 268 | NSMenuItem *foundItem = [ourMenu itemWithTag:[menuId integerValue]]; |
| 269 | if (foundItem != NULL) { |
| 270 | return foundItem; |
| 271 | } |
| 272 | NSArray *menu_items = ourMenu.itemArray; |
| 273 | int i; |
| 274 | for (i = 0; i < [menu_items count]; i++) { |
| 275 | NSMenuItem *i_item = [menu_items objectAtIndex:i]; |
| 276 | if (i_item.hasSubmenu) { |
| 277 | foundItem = find_menu_item(i_item.submenu, menuId); |
| 278 | if (foundItem != NULL) { |
| 279 | return foundItem; |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | return NULL; |
| 285 | }; |
| 286 | |
| 287 | - (void) add_separator:(NSNumber*) parentMenuId |
| 288 | { |
| 289 | if (parentMenuId.integerValue != 0) { |
| 290 | NSMenuItem* menuItem = find_menu_item(menu, parentMenuId); |
| 291 | if (menuItem != NULL) { |
| 292 | [menuItem.submenu addItem: [NSMenuItem separatorItem]]; |
| 293 | return; |
| 294 | } |
| 295 | } |
| 296 | [menu addItem: [NSMenuItem separatorItem]]; |
| 297 | } |
| 298 | |
| 299 | - (void) hide_menu_item:(NSNumber*) menuId |
| 300 | { |
| 301 | NSMenuItem* menuItem = find_menu_item(menu, menuId); |
| 302 | if (menuItem != NULL) { |
| 303 | [menuItem setHidden:TRUE]; |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | - (void) setMenuItemIcon:(NSArray*)imageAndMenuId { |
| 308 | NSImage* image = [imageAndMenuId objectAtIndex:0]; |
| 309 | NSNumber* menuId = [imageAndMenuId objectAtIndex:1]; |
| 310 | |
| 311 | NSMenuItem* menuItem; |
| 312 | menuItem = find_menu_item(menu, menuId); |
| 313 | if (menuItem == NULL) { |
| 314 | return; |
| 315 | } |
| 316 | menuItem.image = image; |
| 317 | } |
| 318 | |
| 319 | - (void)show_menu |
| 320 | { |
| 321 | // Attach the menu and synthesize a click so AppKit positions it natively, |
| 322 | // then detach it in menuDidClose: so the next click reaches the button |
| 323 | // action (and the Go tap handlers) again. |
| 324 | self->statusItem.menu = self->menu; |
| 325 | [self->statusItem.button performClick:nil]; |
| 326 | } |
| 327 | |
| 328 | - (void)menuDidClose:(NSMenu *)menu { |
| 329 | // Defer the detach so we don't pull the menu out from under AppKit |
| 330 | // while it is still tearing the menu down. |
| 331 | dispatch_async(dispatch_get_main_queue(), ^{ |
| 332 | self->statusItem.menu = nil; |
| 333 | }); |
| 334 | } |
| 335 | |
| 336 | - (void) show_menu_item:(NSNumber*) menuId |
| 337 | { |
| 338 | NSMenuItem* menuItem = find_menu_item(menu, menuId); |
| 339 | if (menuItem != NULL) { |
| 340 | [menuItem setHidden:FALSE]; |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | - (void) remove_menu_item:(NSNumber*) menuId |
| 345 | { |
| 346 | NSMenuItem* menuItem = find_menu_item(menu, menuId); |
| 347 | if (menuItem != NULL) { |
| 348 | [menuItem.menu removeItem:menuItem]; |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | - (void) reset_menu |
| 353 | { |
| 354 | [self->menu removeAllItems]; |
| 355 | } |
| 356 | |
| 357 | - (void) quit |
| 358 | { |
| 359 | // This tells the app event loop to stop after processing remaining messages. |
| 360 | [NSApp stop:self]; |
| 361 | // The event loop won't return until it processes another event. |
| 362 | // https://stackoverflow.com/a/48064752/149482 |
| 363 | NSPoint eventLocation = NSMakePoint(0, 0); |
| 364 | NSEvent *customEvent = [NSEvent otherEventWithType:NSEventTypeApplicationDefined |
| 365 | location:eventLocation |
| 366 | modifierFlags:0 |
| 367 | timestamp:0 |
| 368 | windowNumber:0 |
| 369 | context:nil |
| 370 | subtype:0 |
| 371 | data1:0 |
| 372 | data2:0]; |
| 373 | [NSApp postEvent:customEvent atStart:NO]; |
| 374 | } |
| 375 | |
| 376 | @end |
| 377 | |
| 378 | bool internalLoop = false; |
| 379 | SystrayAppDelegate *owner; |
| 380 | |
| 381 | void setInternalLoop(bool i) { |
| 382 | internalLoop = i; |
| 383 | } |
| 384 | |
| 385 | void registerSystray(void) { |
| 386 | if (!internalLoop) { // with an external loop we don't take ownership of the app |
| 387 | return; |
| 388 | } |
| 389 | |
| 390 | owner = [[SystrayAppDelegate alloc] init]; |
| 391 | [[NSApplication sharedApplication] setDelegate:owner]; |
| 392 | |
| 393 | // A workaround to avoid crashing on macOS versions before Catalina. Somehow |
| 394 | // SIGSEGV would happen inside AppKit if [NSApp run] is called from a |
| 395 | // different function, even if that function is called right after this. |
| 396 | if (floor(NSAppKitVersionNumber) <= /*NSAppKitVersionNumber10_14*/ 1671){ |
| 397 | [NSApp run]; |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | void nativeEnd(void) { |
| 402 | systray_on_exit(); |
| 403 | } |
| 404 | |
| 405 | int nativeLoop(void) { |
| 406 | if (floor(NSAppKitVersionNumber) > /*NSAppKitVersionNumber10_14*/ 1671){ |
| 407 | [NSApp run]; |
| 408 | } |
| 409 | return EXIT_SUCCESS; |
| 410 | } |
| 411 | |
| 412 | void nativeStart(void) { |
| 413 | owner = [[SystrayAppDelegate alloc] init]; |
| 414 | |
| 415 | NSNotification *launched = [NSNotification notificationWithName:NSApplicationDidFinishLaunchingNotification |
| 416 | object:[NSApplication sharedApplication]]; |
| 417 | [owner applicationDidFinishLaunching:launched]; |
| 418 | } |
| 419 | |
| 420 | void runInMainThread(SEL method, id object) { |
| 421 | [owner |
| 422 | performSelectorOnMainThread:method |
| 423 | withObject:object |
| 424 | waitUntilDone: YES]; |
| 425 | } |
| 426 | |
| 427 | void setIcon(const char* iconBytes, int length, bool template) { |
| 428 | NSData* buffer = [NSData dataWithBytes: iconBytes length:length]; |
| 429 | @autoreleasepool { |
| 430 | NSImage *image = [[NSImage alloc] initWithData:buffer]; |
| 431 | [image setSize:NSMakeSize(16, 16)]; |
| 432 | image.template = template; |
| 433 | runInMainThread(@selector(setIcon:), (id)image); |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template) { |
| 438 | NSData* buffer = [NSData dataWithBytes: iconBytes length:length]; |
| 439 | @autoreleasepool { |
| 440 | NSImage *image = [[NSImage alloc] initWithData:buffer]; |
| 441 | [image setSize:NSMakeSize(16, 16)]; |
| 442 | image.template = template; |
| 443 | NSNumber *mId = [NSNumber numberWithInt:menuId]; |
| 444 | runInMainThread(@selector(setMenuItemIcon:), @[image, (id)mId]); |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | void setTitle(char* ctitle) { |
| 449 | NSString* title = [[NSString alloc] initWithCString:ctitle |
| 450 | encoding:NSUTF8StringEncoding]; |
| 451 | free(ctitle); |
| 452 | runInMainThread(@selector(setTitle:), (id)title); |
| 453 | } |
| 454 | |
| 455 | void setTooltip(char* ctooltip) { |
| 456 | NSString* tooltip = [[NSString alloc] initWithCString:ctooltip |
| 457 | encoding:NSUTF8StringEncoding]; |
| 458 | free(ctooltip); |
| 459 | runInMainThread(@selector(setTooltip:), (id)tooltip); |
| 460 | } |
| 461 | |
| 462 | void setRemovalAllowed(bool allowed) { |
| 463 | if (allowed) { |
| 464 | runInMainThread(@selector(setRemovalAllowed), nil); |
| 465 | } else { |
| 466 | runInMainThread(@selector(setRemovalForbidden), nil); |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | void add_or_update_menu_item(int menuId, int parentMenuId, char* title, char* tooltip, char* shortcutKey, unsigned int shortcutMods, short disabled, short checked, short isCheckable) { |
| 471 | MenuItem* item = [[MenuItem alloc] initWithId: menuId withParentMenuId: parentMenuId withTitle: title withTooltip: tooltip withShortcutKey: shortcutKey withShortcutMods: shortcutMods withDisabled: disabled withChecked: checked]; |
| 472 | free(title); |
| 473 | free(tooltip); |
| 474 | free(shortcutKey); |
| 475 | runInMainThread(@selector(add_or_update_menu_item:), (id)item); |
| 476 | } |
| 477 | |
| 478 | void add_separator(int menuId, int parentId) { |
| 479 | NSNumber *pId = [NSNumber numberWithInt:parentId]; |
| 480 | runInMainThread(@selector(add_separator:), (id)pId); |
| 481 | } |
| 482 | |
| 483 | void hide_menu_item(int menuId) { |
| 484 | NSNumber *mId = [NSNumber numberWithInt:menuId]; |
| 485 | runInMainThread(@selector(hide_menu_item:), (id)mId); |
| 486 | } |
| 487 | |
| 488 | void remove_menu_item(int menuId) { |
| 489 | NSNumber *mId = [NSNumber numberWithInt:menuId]; |
| 490 | runInMainThread(@selector(remove_menu_item:), (id)mId); |
| 491 | } |
| 492 | |
| 493 | void show_menu() { |
| 494 | runInMainThread(@selector(show_menu), nil); |
| 495 | } |
| 496 | |
| 497 | void show_menu_item(int menuId) { |
| 498 | NSNumber *mId = [NSNumber numberWithInt:menuId]; |
| 499 | runInMainThread(@selector(show_menu_item:), (id)mId); |
| 500 | } |
| 501 | |
| 502 | void reset_menu() { |
| 503 | runInMainThread(@selector(reset_menu), nil); |
| 504 | } |
| 505 | |
| 506 | void quit() { |
| 507 | runInMainThread(@selector(quit), nil); |
| 508 | } |
| 509 |