Skip to content

Commit f556704

Browse files
authored
Merge pull request #1472 from ProspectOre/fix/menu-tracking-zombie-events
Keep switcher shortcut peeks from killing menu tracking sessions
2 parents 0a16bb4 + 29f8fea commit f556704

2 files changed

Lines changed: 48 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Menu bar: restore native macOS positioning for merged provider dropdowns while preparing current content before AppKit lays out the menu.
66
- Settings: memoize cookie cache lookups behind the "Cached: …" picker labels so opening Settings and switching panes no longer pays a synchronous Keychain read per SwiftUI body evaluation, which froze the Providers pane for seconds (#1471). Thanks @ProspectOre!
77
- Build: resolve packaged binaries from the bin path SwiftPM reports instead of assuming the legacy `.build/<arch>-apple-macosx/<conf>/` layout, so a stale directory left behind by the older build system no longer silently shadows fresh swiftbuild products in `package_app.sh`. Thanks @ProspectOre!
8+
- Menu bar: stop the provider-switcher shortcut monitor from killing the menu's event tracking session. Its event-queue peek re-entered the run loop in tracking mode, which could leave a zombie menu on screen that ignored clicks for tens of seconds (beach ball) — most often right after opening the menu or after rapid Cmd-number provider switching, with Settings… the usual victim. Peeks now run in a barren private run-loop mode, start only once the tracking session is pumping, and no longer touch mouse events. Thanks @ProspectOre!
89

910
## 0.34.0 — 2026-06-12
1011

Sources/CodexBar/StatusItemController+ProviderSwitcher.swift

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,34 +71,57 @@ final class ProviderSwitcherEventPeekGate {
7171
}
7272
}
7373

74+
/// Handles provider-switcher keyboard shortcuts and overview scrolling while the merged
75+
/// status menu is open. `NSMenu` tracking pulls events itself, so local event monitors,
76+
/// Carbon dispatcher handlers, registered hot keys (tracking pushes a hotkey-disable mode),
77+
/// and `menuHasKeyEquivalent` never see these events — peeking the queue from a run-loop
78+
/// observer is the only delivery path.
79+
///
80+
/// The peek itself must not disturb the tracking session: `NSApp.nextEvent` re-enters the
81+
/// event loop in the mode it is given, and re-entering `.eventTracking` dispatches the menu
82+
/// session's own timers and sources mid-observer. When that landed during menu setup or amid
83+
/// rapid claimed key repeats, it killed the session and left a zombie menu on screen that no
84+
/// longer dequeued events: clicks sat in the queue for tens of seconds while the cursor
85+
/// beach-balled. Three guards prevent that: peeks run in a private run-loop mode with no
86+
/// sources or timers registered (the queue is mode-agnostic, so matching still works), the
87+
/// peek only starts once the tracking loop is confirmed pumping, and mouse clicks are not
88+
/// monitored at all (`ProviderSwitcherView` handles those via its own `mouseDown`/`mouseUp`
89+
/// overrides), so the monitor never dequeues a click meant for AppKit.
7490
@MainActor
7591
final class ProviderSwitcherShortcutEventMonitor {
7692
private let callback: @MainActor (NSEvent) -> Bool
7793
private let observer: CFRunLoopObserver
94+
private let trackingState = ProviderSwitcherMenuTrackingState()
7895
private var isActive = false
7996

97+
/// A run-loop mode nothing else registers sources or timers in, so running the loop in
98+
/// this mode while polling the event queue cannot dispatch menu-session work re-entrantly.
99+
private static let peekMode = RunLoop.Mode("com.steipete.codexbar.switcher-peek")
100+
80101
init(
81102
events: NSEvent.EventTypeMask,
82103
peekGate: ProviderSwitcherEventPeekGate = ProviderSwitcherEventPeekGate(
83-
eventTypes: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp]),
104+
eventTypes: [.keyDown, .keyUp, .scrollWheel]),
84105
callback: @escaping @MainActor (NSEvent) -> Bool)
85106
{
86107
self.callback = callback
108+
let trackingState = self.trackingState
87109

88110
self.observer = CFRunLoopObserverCreateWithHandler(
89111
nil,
90112
CFRunLoopActivity.beforeSources.rawValue,
91113
true,
92114
0)
93-
{ [events, peekGate, callback] _, _ in
115+
{ [events, peekGate, callback, trackingState] _, _ in
94116
MainActor.assumeIsolated {
117+
guard trackingState.isTrackingActive else { return }
95118
guard peekGate.shouldPeek() else { return }
96119
var foundEvent = false
97120
var blockedByUnhandledEvent = false
98121
while let event = NSApp.nextEvent(
99122
matching: events,
100123
until: .distantPast,
101-
inMode: .eventTracking,
124+
inMode: Self.peekMode,
102125
dequeue: false)
103126
{
104127
foundEvent = true
@@ -110,7 +133,7 @@ final class ProviderSwitcherShortcutEventMonitor {
110133
_ = NSApp.nextEvent(
111134
matching: events,
112135
until: .distantPast,
113-
inMode: .eventTracking,
136+
inMode: Self.peekMode,
114137
dequeue: true)
115138
}
116139
if !blockedByUnhandledEvent {
@@ -133,9 +156,21 @@ final class ProviderSwitcherShortcutEventMonitor {
133156
self.observer,
134157
CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
135158
self.isActive = true
159+
// The menus this monitors are shown via `popUpMenuPositioningItem`, which posts no
160+
// NSMenu tracking notifications. Arm the gate from a block queued in the tracking
161+
// run-loop mode instead: it can only execute once the menu's tracking session is alive
162+
// and pumping the run loop, which keeps peeks away from menu setup.
163+
let trackingState = self.trackingState
164+
RunLoop.main.perform(inModes: [.eventTracking]) {
165+
MainActor.assumeIsolated {
166+
trackingState.isTrackingActive = true
167+
}
168+
}
169+
CFRunLoopWakeUp(CFRunLoopGetMain())
136170
}
137171

138172
func stop() {
173+
self.trackingState.isTrackingActive = false
139174
guard self.isActive else { return }
140175
CFRunLoopRemoveObserver(
141176
RunLoop.main.getCFRunLoop(),
@@ -145,6 +180,13 @@ final class ProviderSwitcherShortcutEventMonitor {
145180
}
146181
}
147182

183+
/// Tracks whether an `NSMenu` tracking session is currently alive, so the shortcut monitor
184+
/// only touches the event queue while AppKit is actually pumping it.
185+
@MainActor
186+
private final class ProviderSwitcherMenuTrackingState {
187+
var isTrackingActive = false
188+
}
189+
148190
extension StatusItemController {
149191
func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) {
150192
guard self.isMenuRefreshEnabled,
@@ -157,9 +199,7 @@ extension StatusItemController {
157199
self.removeProviderSwitcherShortcutMonitor()
158200
self.resetOverviewScrollAccumulation()
159201
let monitor = ProviderSwitcherShortcutEventMonitor(
160-
events: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp, .scrollWheel],
161-
peekGate: ProviderSwitcherEventPeekGate(
162-
eventTypes: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp, .scrollWheel]))
202+
events: [.keyDown, .keyUp, .scrollWheel])
163203
{ [weak self, weak menu] event in
164204
guard let self,
165205
let menu,

0 commit comments

Comments
 (0)