From cbe84c2a13e6c16b91b4ab109f2faad14cfd71ab Mon Sep 17 00:00:00 2001 From: ChewbaccaCookie Date: Thu, 6 Aug 2026 15:35:04 +0200 Subject: [PATCH] fix(systray): recover click type on macOS 27 On macOS 27 Golden Gate, [NSApp currentEvent] inside an NSStatusItem action selector no longer reflects the originating mouse-down, so processClick's left/right switch dropped the event. Coerce via [NSEvent pressedMouseButtons] as a fallback. Behaviour on macOS <=26 is unchanged (the coercion is a no-op when the raw event type is already a mouse-down). Adds a table-driven regression test. Closes #5752 --- v3/pkg/application/systemtray_darwin.go | 6 ++ v3/pkg/application/systemtray_darwin.h | 10 +- v3/pkg/application/systemtray_darwin.m | 18 +++- v3/pkg/application/systemtray_darwin_test.go | 101 +++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 v3/pkg/application/systemtray_darwin_test.go diff --git a/v3/pkg/application/systemtray_darwin.go b/v3/pkg/application/systemtray_darwin.go index 1441c3aa62f..c58bd8dae8f 100644 --- a/v3/pkg/application/systemtray_darwin.go +++ b/v3/pkg/application/systemtray_darwin.go @@ -82,6 +82,12 @@ const ( // system tray map var systemTrayMap = make(map[uint]*macosSystemTray) +// coerceStatusItemEventType wraps the C helper; used by tests. +// See systemtray_darwin.h for the #5752 rationale. +func coerceStatusItemEventType(rawEventType int, pressedMouseButtons uint64) int { + return int(C.systemTrayCoerceEventType(C.int(rawEventType), C.ulong(pressedMouseButtons))) +} + //export systrayClickCallback func systrayClickCallback(id C.long, buttonID C.int) { // Get the system tray diff --git a/v3/pkg/application/systemtray_darwin.h b/v3/pkg/application/systemtray_darwin.h index e1f55d817d1..5d43a3b82d8 100644 --- a/v3/pkg/application/systemtray_darwin.h +++ b/v3/pkg/application/systemtray_darwin.h @@ -25,4 +25,12 @@ void systemTrayGetBounds(void* nsStatusItem, NSRect *rect, void **screen); NSRect NSScreen_frame(void* screen); void windowSetScreen(void* window, void* screen, int yOffset); int statusBarHeight(); -void systemTrayPositionWindow(void* nsStatusItem, void* nsWindow, int offset); \ No newline at end of file +void systemTrayPositionWindow(void* nsStatusItem, void* nsWindow, int offset); + +// systemTrayCoerceEventType maps the raw NSEvent.type observed inside +// -[StatusItemController statusItemClicked:] to a mouse-button event type +// that Go's processClick can dispatch on. On current macOS betas the +// currentEvent at action time is no longer the originating mouse-down +// (typically a later NSEventTypeMouseMoved), so callers fall back to +// [NSEvent pressedMouseButtons]. Exposed for regression testing (#5752). +int systemTrayCoerceEventType(int rawEventType, unsigned long pressedMouseButtons); diff --git a/v3/pkg/application/systemtray_darwin.m b/v3/pkg/application/systemtray_darwin.m index 23f113b2200..12cb679829f 100644 --- a/v3/pkg/application/systemtray_darwin.m +++ b/v3/pkg/application/systemtray_darwin.m @@ -7,12 +7,28 @@ extern void systrayClickCallback(long, int); extern int systrayPreClickCallback(long, int); +// Bit returned by -[NSEvent pressedMouseButtons] for the right mouse button. +static const NSUInteger kRightMouseButtonBit = 1UL << 1; + +// See systemtray_darwin.h for the #5752 rationale. +int systemTrayCoerceEventType(int rawEventType, unsigned long pressedMouseButtons) { + if (rawEventType == NSEventTypeLeftMouseDown || rawEventType == NSEventTypeRightMouseDown) { + return rawEventType; + } + if (pressedMouseButtons & kRightMouseButtonBit) { + return (int)NSEventTypeRightMouseDown; + } + return (int)NSEventTypeLeftMouseDown; +} + // StatusItemController.m @implementation StatusItemController - (void)statusItemClicked:(id)sender { NSEvent *event = [NSApp currentEvent]; - systrayClickCallback(self.id, event.type); + systrayClickCallback(self.id, + systemTrayCoerceEventType((int)event.type, + (unsigned long)[NSEvent pressedMouseButtons])); } - (void)menuDidClose:(NSMenu *)menu { diff --git a/v3/pkg/application/systemtray_darwin_test.go b/v3/pkg/application/systemtray_darwin_test.go new file mode 100644 index 00000000000..813c580757c --- /dev/null +++ b/v3/pkg/application/systemtray_darwin_test.go @@ -0,0 +1,101 @@ +//go:build darwin && !ios && !server + +package application + +import "testing" + +// NSEventType and pressedMouseButtons bit values from AppKit, duplicated +// here so the test does not need to import AppKit. +const ( + testNSEventTypeLeftMouseDown = 1 + testNSEventTypeRightMouseDown = 3 + testNSEventTypeLeftMouseUp = 2 + testNSEventTypeMouseMoved = 5 + testNSEventTypeMouseEntered = 8 + testNSEventTypeCursorUpdate = 17 + + testPressedNone = 0 + testPressedLeft = 1 << 0 + testPressedRight = 1 << 1 +) + +// TestCoerceStatusItemEventType_MacOS27Regression pins the fix for #5752. +// See systemtray_darwin.h for the rationale. +func TestCoerceStatusItemEventType_MacOS27Regression(t *testing.T) { + tests := []struct { + name string + rawEventType int + pressed uint64 + wantEventType int + }{ + // macOS <=26 path: currentEvent still is the mouse-down; helper is + // a no-op. + { + name: "left mouse-down passes through unchanged (<=26)", + rawEventType: testNSEventTypeLeftMouseDown, + pressed: testPressedLeft, + wantEventType: testNSEventTypeLeftMouseDown, + }, + { + name: "right mouse-down passes through unchanged (<=26)", + rawEventType: testNSEventTypeRightMouseDown, + pressed: testPressedRight, + wantEventType: testNSEventTypeRightMouseDown, + }, + + // macOS 27 regression path: raw type is *not* a mouse-down, so the + // helper reads pressedMouseButtons to recover the intended button. + // The MouseMoved+pressed=1 case is the exact combination observed + // in field logs on macOS 27 for a plain left click on the tray icon. + { + name: "macOS 27: MouseMoved with left pressed -> LeftMouseDown", + rawEventType: testNSEventTypeMouseMoved, + pressed: testPressedLeft, + wantEventType: testNSEventTypeLeftMouseDown, + }, + { + name: "macOS 27: MouseMoved with right pressed -> RightMouseDown", + rawEventType: testNSEventTypeMouseMoved, + pressed: testPressedRight, + wantEventType: testNSEventTypeRightMouseDown, + }, + { + name: "macOS 27: CursorUpdate with left pressed -> LeftMouseDown", + rawEventType: testNSEventTypeCursorUpdate, + pressed: testPressedLeft, + wantEventType: testNSEventTypeLeftMouseDown, + }, + { + name: "macOS 27: MouseEntered with no button pressed -> LeftMouseDown (default)", + rawEventType: testNSEventTypeMouseEntered, + pressed: testPressedNone, + wantEventType: testNSEventTypeLeftMouseDown, + }, + { + name: "macOS 27: LeftMouseUp with left pressed -> LeftMouseDown", + rawEventType: testNSEventTypeLeftMouseUp, + pressed: testPressedLeft, + wantEventType: testNSEventTypeLeftMouseDown, + }, + + // Right takes precedence when both bits are set: this matches + // existing right-click semantics (right-click hides the attached + // window and opens the menu). + { + name: "both buttons pressed -> RightMouseDown wins", + rawEventType: testNSEventTypeMouseMoved, + pressed: testPressedLeft | testPressedRight, + wantEventType: testNSEventTypeRightMouseDown, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := coerceStatusItemEventType(tc.rawEventType, tc.pressed) + if got != tc.wantEventType { + t.Fatalf("coerceStatusItemEventType(raw=%d, pressed=%#x) = %d, want %d", + tc.rawEventType, tc.pressed, got, tc.wantEventType) + } + }) + } +}