From 81fdeee5d67a3218ce0d0a8160a01b29233b25ca Mon Sep 17 00:00:00 2001 From: theparlor Date: Thu, 11 Jun 2026 03:56:56 -0400 Subject: [PATCH] fix(mac): restore right-click status-item menu on macOS 27 (#472) Present the right-click menu from a global right-mouse-down monitor (hit-tested against the status-item window) via NSMenu.popUp, since macOS 27 no longer routes right-mouse events to the status-item action. Legacy action path retained for macOS <= 26, guarded by a debounce. Remove the global monitor on terminate. --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index d2686c45..d4303871 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -32,6 +32,8 @@ struct CodeBurnApp: App { final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! + private var rightClickMonitor: Any? + private var lastContextMenuPresentedAt: Date = .distantPast fileprivate let store = AppStore() let updateChecker = UpdateChecker() /// Held for the lifetime of the app to opt out of App Nap and Automatic Termination. @@ -50,6 +52,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var codexQuotaRefreshTask: Task? private var refreshLoopHeartbeatAt: Date = .distantPast + func applicationWillTerminate(_ notification: Notification) { + if let monitor = rightClickMonitor { + NSEvent.removeMonitor(monitor) + rightClickMonitor = nil + } + } + func applicationWillFinishLaunching(_ notification: Notification) { // Set accessory policy before the app's focus chain forms. On macOS Tahoe // (26.x), setting it after didFinishLaunching causes ghost status items @@ -622,8 +631,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { button.target = self button.action = #selector(handleButtonClick(_:)) + // Left-click drives the popover. We keep .rightMouseUp in the mask so the + // legacy action path below still fires on macOS <= 26; on macOS 27 the + // system consumes the right button entirely and this never fires, so the + // global monitor (below) is what restores the right-click menu there. button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + // macOS 27 no longer routes any right-mouse event to the status-item + // button's target/action. A global monitor still observes right-mouse-down; + // we hit-test it against our own status-item window and present the menu + // ourselves. Harmless and stable on 15/26 too (the debounce in + // showContextMenu prevents a double-present if the legacy path also fires). + rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in + guard let self, + let button = self.statusItem.button, + let window = button.window, + window.frame.contains(NSEvent.mouseLocation) else { return } + DispatchQueue.main.async { self.showContextMenu(from: button) } + } + // Defer the full attributed title setup to ensure initial render completes DispatchQueue.main.async { [weak self] in self?.refreshStatusButton() @@ -783,6 +809,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { guard let button = statusItem.button, let event = NSApp.currentEvent else { return } + // Legacy right-click path for macOS <= 26 (no-op on 27, where the action + // never receives a right-mouse event — the global monitor handles it). if event.type == .rightMouseUp { showContextMenu(from: button) return @@ -816,6 +844,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } private func showContextMenu(from button: NSStatusBarButton) { + // Debounce: on macOS <= 26 both the legacy action path and the global + // monitor can fire for a single right-click. Present at most once per click. + let now = Date() + guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return } + lastContextMenuPresentedAt = now + let menu = NSMenu() let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",") @@ -835,9 +869,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { quitItem.target = self menu.addItem(quitItem) - statusItem.menu = menu - button.performClick(nil) - statusItem.menu = nil + // Present directly. The previous `statusItem.menu = menu; button.performClick` + // trick relies on the click -> action path that macOS 27 changed; popUp is + // version-stable. + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button) } private var settingsWindowController: NSWindowController?