From 2dda80e132b8d91d337a0ea6c7ffbebb07b61521 Mon Sep 17 00:00:00 2001 From: David Liebovitz Date: Sun, 14 Jun 2026 09:36:57 -0500 Subject: [PATCH] fix(sender): stop the status menu from orphaning a click-eating window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the menu bar icon's menu and then selecting an item left a persistent "dead zone" below the icon: a rectangle where the cursor was visible but hover/clicks never landed, cleared only by quitting the app. A window dump while the zone was present showed a TargetBridge-owned, layer-101 (menu/popup), alpha-0 window at the menu's exact 974x272 footprint — the menu's backing window had faded out but was never closed. Two changes remove the orphaning: - Assign one NSMenu for the status item's lifetime and repopulate it lazily via NSMenuDelegate.menuNeedsUpdate(_:), instead of reassigning item.menu = makeMenu() on every service.objectWillChange. Swapping the menu object while it is open/tracking can strand its window. - Defer each menu-item handler to the next runloop tick. The handlers ran synchronously while the menu was still dismissing; activating the app, ordering windows front, or mutating observed session state mid-dismissal interrupted the fade-out so the window reached alpha 0 but was never removed. Deferring lets the menu fully tear down first. This was the primary cause — the dead zone only appeared after selecting an item, not on plain open/close. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TBDisplaySenderStatusItemController.swift | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift index eb7d4e1..e86f08f 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift @@ -69,6 +69,15 @@ final class TBDisplaySenderStatusItemController: NSObject { item.button?.image = NSImage(systemSymbolName: "display.2", accessibilityDescription: "TargetBridge") item.button?.imagePosition = .imageOnly item.button?.toolTip = TBDisplaySenderL10n.topBarToolTip(service.language) + + // Assign one menu instance for the lifetime of the status item and + // repopulate it lazily in `menuNeedsUpdate(_:)`. Swapping `item.menu` + // out from under an open/tracking menu leaves macOS holding an + // orphaned, invisible menu window that swallows clicks at the menu's + // location — the "dead zone" below the menu bar icon. + let menu = NSMenu() + menu.delegate = self + item.menu = menu statusItem = item } @@ -81,11 +90,10 @@ final class TBDisplaySenderStatusItemController: NSObject { private func refreshStatusItem() { guard let item = statusItem else { return } item.button?.toolTip = TBDisplaySenderL10n.topBarToolTip(service.language) - item.menu = makeMenu() } - private func makeMenu() -> NSMenu { - let menu = NSMenu() + private func rebuildMenuItems(in menu: NSMenu) { + menu.removeAllItems() let titleItem = NSMenuItem(title: "TargetBridge", action: nil, keyEquivalent: "") titleItem.isEnabled = false @@ -148,35 +156,59 @@ final class TBDisplaySenderStatusItemController: NSObject { let quitItem = NSMenuItem(title: TBDisplaySenderL10n.quitApp(service.language), action: #selector(quitApp), keyEquivalent: "q") quitItem.target = self menu.addItem(quitItem) + } - return menu + // Menu-item handlers run while the menu is still dismissing. Doing work + // synchronously here (activating the app, ordering windows front, mutating + // observed session state) interrupts the menu window's fade-out: its alpha + // animates to 0 but the window is never closed, leaving an invisible + // menu-layer window that swallows clicks at the menu's footprint. Deferring + // to the next runloop tick lets the menu fully tear down first. + private func runAfterMenuDismissal(_ action: @escaping () -> Void) { + DispatchQueue.main.async(execute: action) } @objc private func showMainWindow() { - NSApp.activate(ignoringOtherApps: true) - for window in NSApp.windows { - window.makeKeyAndOrderFront(nil) + runAfterMenuDismissal { + NSApp.activate(ignoringOtherApps: true) + for window in NSApp.windows { + window.makeKeyAndOrderFront(nil) + } } } @objc private func addSession() { - service.addSession() + runAfterMenuDismissal { [service] in + service.addSession() + } } @objc private func stopAll() { - service.stopAll() + runAfterMenuDismissal { [service] in + service.stopAll() + } } @objc private func hideStatusItem() { - service.showsMenuBarIcon = false + runAfterMenuDismissal { [service] in + service.showsMenuBarIcon = false + } } @objc private func quitApp() { - NSApp.terminate(nil) + runAfterMenuDismissal { + NSApp.terminate(nil) + } + } +} + +extension TBDisplaySenderStatusItemController: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + rebuildMenuItems(in: menu) } }