Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions v3/UNRELEASED_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ After processing, the content will be moved to the main changelog and this file

## Fixed
<!-- Bug fixes -->
- Fix system tray menu highlight state on macOS - icon now shows selected state when menu is open (#4910)
- Fix system tray attached window appearing behind other windows on macOS - now uses proper popup window level (#4910)

## Deprecated
<!-- Soon-to-be removed features -->
Expand Down
38 changes: 38 additions & 0 deletions v3/pkg/application/systemtray_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,48 @@ func systrayClickCallback(id C.long, buttonID C.int) {
systemTray.processClick(button(buttonID))
}

// systrayPreClickCallback is called from the NSEvent local monitor BEFORE the
// button processes the mouse-down. It returns 1 when the framework should
// show the menu via native tracking (proper highlight, no app activation),
// or 0 to let the action handler fire for custom click/window behaviour.
//
//export systrayPreClickCallback
func systrayPreClickCallback(id C.long, buttonID C.int) C.int {
systemTray := systemTrayMap[uint(id)]
if systemTray == nil || systemTray.nsMenu == nil {
return 0
}
b := button(buttonID)
switch b {
case leftButtonDown:
if systemTray.parent.clickHandler == nil &&
systemTray.parent.attachedWindow.Window == nil {
return 1
}
case rightButtonDown:
if systemTray.parent.rightClickHandler == nil {
// Hide the attached window before the menu appears.
if systemTray.parent.attachedWindow.Window != nil &&
systemTray.parent.attachedWindow.Window.IsVisible() {
systemTray.parent.attachedWindow.Window.Hide()
}
return 1
}
}
return 0
}

func (s *macosSystemTray) setIconPosition(position IconPosition) {
s.iconPosition = position
}

func (s *macosSystemTray) setMenu(menu *Menu) {
s.menu = menu
if s.nsStatusItem != nil && menu != nil {
menu.Update()
s.nsMenu = (menu.impl).(*macosMenu).nsMenu
C.systemTraySetCachedMenu(s.nsStatusItem, s.nsMenu)
Comment on lines +133 to +136
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

systemTraySetCachedMenu is only called when menu != nil, so calling SetMenu(nil) leaves s.nsMenu and the ObjC cachedMenu pointing at the previous menu. Because systrayPreClickCallback gates on systemTray.nsMenu != nil, right-click can still show the stale menu after it has been cleared. When menu == nil, explicitly set s.nsMenu = nil and clear the cached menu on the ObjC controller as well.

Suggested change
if s.nsStatusItem != nil && menu != nil {
menu.Update()
s.nsMenu = (menu.impl).(*macosMenu).nsMenu
C.systemTraySetCachedMenu(s.nsStatusItem, s.nsMenu)
if s.nsStatusItem == nil {
return
}
if menu != nil {
menu.Update()
s.nsMenu = (menu.impl).(*macosMenu).nsMenu
C.systemTraySetCachedMenu(s.nsStatusItem, s.nsMenu)
} else {
s.nsMenu = nil
C.systemTraySetCachedMenu(s.nsStatusItem, nil)

Copilot uses AI. Check for mistakes.
}
}
Comment on lines 131 to 138
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

systemTraySetCachedMenu is called outside the main thread.

systemTraySetCachedMenu in the .m file accesses ObjC objects directly without dispatch_async(dispatch_get_main_queue(), …). If setMenu is ever called from a non-main goroutine while the event monitor is reading cachedMenu, this is a data race.

The same concern applies to the existing s.nsMenu assignment on line 135, so this is a pre-existing pattern — but the new cachedMenu is actively read by the event monitor on the main thread, increasing the risk.

Suggested fix: dispatch the cache update to the main thread

In systemtray_darwin.m:

 void systemTraySetCachedMenu(void* nsStatusItem, void *nsMenu) {
-	NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
-	StatusItemController *controller = (StatusItemController *)[statusItem target];
-	controller.cachedMenu = (NSMenu *)nsMenu;
+	dispatch_async(dispatch_get_main_queue(), ^{
+		NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
+		StatusItemController *controller = (StatusItemController *)[statusItem target];
+		controller.cachedMenu = (NSMenu *)nsMenu;
+	});
 }
🤖 Prompt for AI Agents
In `@v3/pkg/application/systemtray_darwin.go` around lines 131 - 138, The call to
C.systemTraySetCachedMenu and the assignment of s.nsMenu must happen on the main
thread to avoid races with the event monitor; modify macosSystemTray.setMenu so
that after computing ns := (menu.impl).(*macosMenu).nsMenu you dispatch a block
to the main queue (e.g. via a C helper that uses
dispatch_async(dispatch_get_main_queue(), ...)) which sets s.nsMenu = ns and
calls C.systemTraySetCachedMenu(s.nsStatusItem, ns), leaving menu.Update() on
the caller goroutine; ensure you reference setMenu, s.nsStatusItem, s.nsMenu,
macosMenu.nsMenu and C.systemTraySetCachedMenu when making the change.


func (s *macosSystemTray) positionWindow(window Window, offset int) error {
Expand Down Expand Up @@ -167,6 +203,8 @@ func (s *macosSystemTray) run() {
s.menu.Update()
// Convert impl to macosMenu object
s.nsMenu = (s.menu.impl).(*macosMenu).nsMenu
// Cache on the ObjC controller for the event monitor.
C.systemTraySetCachedMenu(s.nsStatusItem, s.nsMenu)
}
})
}
Expand Down
6 changes: 5 additions & 1 deletion v3/pkg/application/systemtray_darwin.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

#include <Cocoa/Cocoa.h>

@interface StatusItemController : NSObject
@interface StatusItemController : NSObject <NSMenuDelegate>
@property long id;
@property (assign) NSStatusItem *statusItem;
@property (assign) NSMenu *cachedMenu;
@property (strong) id eventMonitor;
- (void)statusItemClicked:(id)sender;
@end

Expand All @@ -17,6 +20,7 @@ NSImage* imageFromBytes(const unsigned char *bytes, int length);
void systemTraySetIcon(void* nsStatusItem, void* nsImage, int position, bool isTemplate);
void systemTrayDestroy(void* nsStatusItem);
void showMenu(void* nsStatusItem, void *nsMenu);
void systemTraySetCachedMenu(void* nsStatusItem, void *nsMenu);
void systemTrayGetBounds(void* nsStatusItem, NSRect *rect, void **screen);
NSRect NSScreen_frame(void* screen);
void windowSetScreen(void* window, void* screen, int yOffset);
Expand Down
104 changes: 81 additions & 23 deletions v3/pkg/application/systemtray_darwin.m
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "systemtray_darwin.h"

extern void systrayClickCallback(long, int);
extern int systrayPreClickCallback(long, int);

// StatusItemController.m
@implementation StatusItemController
Expand All @@ -14,17 +15,43 @@ - (void)statusItemClicked:(id)sender {
systrayClickCallback(self.id, event.type);
}

- (void)menuDidClose:(NSMenu *)menu {
// Remove the menu from the status item so future clicks invoke the
// action handler instead of re-showing the menu.
self.statusItem.menu = nil;
menu.delegate = nil;
}

@end

// Create a new system tray
void* systemTrayNew(long id) {
StatusItemController *controller = [[StatusItemController alloc] init];
controller.id = id;
NSStatusItem *statusItem = [[[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength] retain];
controller.statusItem = statusItem;
[statusItem setTarget:controller];
[statusItem setAction:@selector(statusItemClicked:)];
NSButton *button = statusItem.button;
[button sendActionOn:(NSEventMaskLeftMouseDown|NSEventMaskRightMouseDown)];

// Install a local event monitor that fires BEFORE the button processes
// the mouse-down. When the pre-click callback says "show menu", we
// temporarily set statusItem.menu so the button enters native menu
// tracking — this gives proper highlight and does not activate the app.
controller.eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:
(NSEventMaskLeftMouseDown|NSEventMaskRightMouseDown)
handler:^NSEvent *(NSEvent *event) {
if (event.window != button.window) return event;

int action = systrayPreClickCallback((long)controller.id, (int)event.type);
if (action == 1 && controller.cachedMenu != nil) {
controller.cachedMenu.delegate = controller;
statusItem.menu = controller.cachedMenu;
}
Comment on lines +42 to +51
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local NSEvent monitor only checks event.window != button.window to decide whether to handle the click. All status bar items share the same status bar window, so this monitor can run for clicks on other menu bar items and may leave statusItem.menu set without ever opening/closing the menu (breaking subsequent click behavior). Add a stricter hit-test (e.g., ensure the event location is within button.frame / hitTest: equals the button) before calling systrayPreClickCallback / setting statusItem.menu.

Copilot uses AI. Check for mistakes.
return event;
}];

return (void*)statusItem;
}

Expand Down Expand Up @@ -135,31 +162,56 @@ void systemTrayDestroy(void* nsStatusItem) {
// Remove the status item from the status bar and its associated menu
dispatch_async(dispatch_get_main_queue(), ^{
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
StatusItemController *controller = (StatusItemController *)[statusItem target];
if (controller.eventMonitor) {
[NSEvent removeMonitor:controller.eventMonitor];
controller.eventMonitor = nil;
}
[[NSStatusBar systemStatusBar] removeStatusItem:statusItem];
[controller release];
[statusItem release];
});
}

// showMenu is used for programmatic OpenMenu() calls. Click-triggered
// menus are handled by the event monitor installed in systemTrayNew.
Comment on lines +176 to +177
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above showMenu() says click-triggered menus are handled by the event monitor, but the Go click path can still call showMenu() (e.g., via defaultClickHandler -> OpenMenu()), and left-click currently won’t take the event-monitor branch because clickHandler is non-nil by default. Update the comment to reflect the actual call paths so future changes don’t rely on incorrect assumptions.

Suggested change
// showMenu is used for programmatic OpenMenu() calls. Click-triggered
// menus are handled by the event monitor installed in systemTrayNew.
// showMenu is used for programmatic OpenMenu() calls and for menus opened
// from Go click handlers (e.g., defaultClickHandler -> OpenMenu()). Some
// click-triggered menus may also be initiated by the event monitor installed
// in systemTrayNew, but they still ultimately flow through this function.

Copilot uses AI. Check for mistakes.
void showMenu(void* nsStatusItem, void *nsMenu) {
// Show the menu on the main thread
dispatch_async(dispatch_get_main_queue(), ^{
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
[statusItem popUpStatusItemMenu:(NSMenu *)nsMenu];
// Post a mouse up event so the statusitem defocuses
NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseUp
location:[NSEvent mouseLocation]
modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber:0
context:nil
eventNumber:0
clickCount:1
pressure:1];
[NSApp postEvent:event atStart:NO];
[statusItem.button highlight:NO];
NSMenu *menu = (NSMenu *)nsMenu;
StatusItemController *controller = (StatusItemController *)[statusItem target];

// Temporarily assign the menu for native tracking.
menu.delegate = controller;
statusItem.menu = menu;

// Synthesize a mouse-down at the button centre to trigger native
// menu tracking (highlights the button, blocks until dismissed).
NSRect frame = [statusItem.button convertRect:statusItem.button.bounds toView:nil];
NSPoint loc = NSMakePoint(NSMidX(frame), NSMidY(frame));
NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseDown
location:loc
modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber:statusItem.button.window.windowNumber
context:nil
eventNumber:0
clickCount:1
pressure:1.0];
[statusItem.button mouseDown:event];

// Menu dismissed — restore custom click handling.
statusItem.menu = nil;
menu.delegate = nil;
});
}

void systemTraySetCachedMenu(void* nsStatusItem, void *nsMenu) {
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
StatusItemController *controller = (StatusItemController *)[statusItem target];
controller.cachedMenu = (NSMenu *)nsMenu;
}

void systemTrayGetBounds(void* nsStatusItem, NSRect *rect, void **outScreen) {
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
NSStatusBarButton *button = statusItem.button;
Expand Down Expand Up @@ -201,25 +253,25 @@ int statusBarHeight() {
void systemTrayPositionWindow(void* nsStatusItem, void* nsWindow, int offset) {
// Get the status item's button
NSStatusBarButton *button = [(NSStatusItem*)nsStatusItem button];

// Get the frame in screen coordinates
NSRect frame = [button.window convertRectToScreen:button.frame];

// Get the screen that contains the status item
NSScreen *screen = [button.window screen];
if (screen == nil) {
screen = [NSScreen mainScreen];
}

// Get screen's backing scale factor (DPI)
CGFloat scaleFactor = [screen backingScaleFactor];

// Get the window's frame
NSRect windowFrame = [(NSWindow*)nsWindow frame];

// Calculate the horizontal position (centered under the status item)
CGFloat windowX = frame.origin.x + (frame.size.width - windowFrame.size.width) / 2;

// If the window would go off the right edge of the screen, adjust it
if (windowX + windowFrame.size.width > screen.frame.origin.x + screen.frame.size.width) {
windowX = screen.frame.origin.x + screen.frame.size.width - windowFrame.size.width;
Expand All @@ -228,17 +280,23 @@ void systemTrayPositionWindow(void* nsStatusItem, void* nsWindow, int offset) {
if (windowX < screen.frame.origin.x) {
windowX = screen.frame.origin.x;
}

// Get screen metrics
NSRect screenFrame = [screen frame];
NSRect visibleFrame = [screen visibleFrame];

// Calculate the vertical position
CGFloat scaledOffset = offset * scaleFactor;
CGFloat windowY = visibleFrame.origin.y + visibleFrame.size.height - windowFrame.size.height - scaledOffset;

// Set the window's frame
windowFrame.origin.x = windowX;
windowFrame.origin.y = windowY;
[(NSWindow*)nsWindow setFrame:windowFrame display:YES animate:NO];

// Set window level to popup menu level so it appears above other windows
[(NSWindow*)nsWindow setLevel:NSPopUpMenuWindowLevel];

// Bring window to front
[(NSWindow*)nsWindow orderFrontRegardless];
Comment on lines +300 to +301
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

systemTrayPositionWindow() now calls orderFrontRegardless, which has visible side effects (it can show/raise the window) even when the caller only intends to reposition it. Since SystemTray.PositionWindow() is a public API, this changes behavior beyond the attached-window use case. Consider removing orderFrontRegardless from positioning, or at least gating it on [(NSWindow*)nsWindow isVisible] / moving the bring-to-front behavior to the code path that actually shows the attached window.

Suggested change
// Bring window to front
[(NSWindow*)nsWindow orderFrontRegardless];
// Bring window to front only if it is already visible, to avoid changing
// behavior of callers that only intend to reposition the window.
if ([(NSWindow*)nsWindow isVisible]) {
[(NSWindow*)nsWindow orderFrontRegardless];
}

Copilot uses AI. Check for mistakes.
Comment on lines +296 to +301
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

NSPopUpMenuWindowLevel likely causes the greyed-out controls reported by the reviewer.

NSPopUpMenuWindowLevel (101) combined with orderFrontRegardless (no app activation) puts the window above everything but leaves the previously focused app in an "inactive" state — hence greyed-out close/minimize/maximize buttons in apps like Chrome.

Native macOS status-item popups (Wi-Fi, Bluetooth, etc.) typically use an NSPanel with NSWindowStyleMaskNonactivatingPanel so they can overlay other windows without deactivating them. Consider:

  1. Using NSStatusWindowLevel (25) instead — still above normal windows but less aggressive.
  2. Or configuring the attached window as a non-activating panel, which is the standard macOS pattern for status-item popups.

Additionally, this level is set every time systemTrayPositionWindow is called but never reset, so the window permanently stays at popup-menu level even if it's later repositioned or used differently.

🤖 Prompt for AI Agents
In `@v3/pkg/application/systemtray_darwin.m` around lines 296 - 301, The code in
systemTrayPositionWindow sets the window level to NSPopUpMenuWindowLevel and
calls orderFrontRegardless, which can leave other apps inactive and produce
greyed-out controls; change this to use NSStatusWindowLevel or convert the
window to a non-activating panel (NSPanel with
NSWindowStyleMaskNonactivatingPanel) so the popup overlays without deactivating
the app, and replace orderFrontRegardless with a fronting call appropriate for
non-activating panels; also ensure the level change is applied only when showing
the tray popup (and restore or avoid persisting NSPopUpMenuWindowLevel) so the
window does not remain at popup level after repositioning.

}
Loading