Stop focus polling from dismissing Calendar's event popover (closes #476)#483
Conversation
When Cotabby is disabled (globally or for a specific app), FocusTracker still polled the frontmost app's AX tree every 50-80ms via FocusSnapshotResolver.resolveSnapshot, which enumerates AX attributes on the focused element and its candidates. Apple Calendar's event-creation popover self-dismisses when its AX tree is read out from underneath it, which is why the bug persisted until the process was quit even after toggling the global / per-app disable. Gate the deep AX walk on the same flags the input layer already checks: isGloballyEnabled and isApplicationDisabled(bundleIdentifier:). On a suppressed tick the tracker still publishes an inactive snapshot with the bundle identifier so downstream consumers (the input short-circuit, the activation indicator) know which app is focused and can stay coherent. Closes #476.
| private func noteCaptureSuppressed(for application: NSRunningApplication) { | ||
| let bundleIdentifier = application.bundleIdentifier | ||
| guard lastSuppressedBundleIdentifier != bundleIdentifier else { | ||
| return | ||
| } | ||
| lastSuppressedBundleIdentifier = bundleIdentifier | ||
| let name = application.localizedName ?? "?" | ||
| let id = bundleIdentifier ?? "no bundle id" | ||
| CotabbyLogger.focus.info("Focus capture suppressed for \(name) (\(id))") | ||
| } |
There was a problem hiding this comment.
Suppression logging silently drops when
bundleIdentifier is nil
lastSuppressedBundleIdentifier is String?. When application.bundleIdentifier is nil, the comparison lastSuppressedBundleIdentifier != bundleIdentifier evaluates as nil != nil → false, so the guard exits immediately and lastSuppressedBundleIdentifier is never updated. Two consequences: (1) "Focus capture suppressed for …" is never logged for a nil-bundle-ID app even on the first entry, and (2) because lastSuppressedBundleIdentifier stays nil, noteCaptureResumedIfNeeded also silently skips the "Focus capture resumed" line when leaving that app. In practice this only fires when Cotabby is globally disabled and the frontmost app has no bundle ID — an uncommon edge case — but the manual validation step in the PR description that asks devs to tail the log could be misleading if they hit this path.
Summary
FocusTrackerpolls the frontmost app's AX tree every 50-80ms and runsFocusSnapshotResolver.resolveSnapshot, which enumerates AX attributes on the focused element and its candidates. Apple Calendar's event-creation popover self-dismisses when its AX tree is read out from underneath it, which is the cause of #476.The polling loop is ungated: it does the deep AX walk regardless of
isGloballyEnabledanddisabledAppRules. This is why the reporter saw the bug persist after toggling both off; only quitting Cotabby stopped the polling. Gate the walk on the same two flags the input layer already checks, and publish an inactive snapshot (with the bundle identifier preserved) on suppressed ticks so downstream consumers stay coherent.Investigation was done with a fan-out of 6 read-only investigators (Workflow). Five of six landed on FocusTracker as the most likely cause. The other prong they suggested (CGEvent tap teardown on disable) was ruled out by reading
InputMonitor: the steady-state tap is.listenOnly, which cannot drop or modify events (the active accept tap is only installed when an overlay is visible). So the fix is just the focus gate.Draft because I want a maintainer with a Calendar repro to confirm before merging, but I'm confident in the root cause.
Validation
Manual repro the reporter (or a maintainer) can run:
disabledAppRules, create a new event in Calendar. The detail popover should now stay open and date/time/alerts should be editable.~/Library/Logs/Cotabby/cotabby.jsonland filter for category=focus. While Calendar is focused you should see oneFocus capture suppressed for Calendar (com.apple.iCal)line on entry and oneFocus capture resumedline when switching to another app. If those lines do not appear, the gate is not engaging.Linked issues
Closes #476.
Risk / rollout notes
FocusTracker.init->FocusTrackingModel.init->CotabbyAppEnvironment. The new parameter is defaulted to{ _ in false }everywhere, so any caller that does not pass it (none exist outside the env) keeps the previous behavior..blocked("Cotabby is disabled for this app.")instead of.supported. The menu bar status label now reads "Blocked" (the existing capability shortLabel) when the user is focused inside a disabled app. This is arguably correct, but flagging it as a small user-visible behavior change..listenOnly([Bug] Messing with Davinci Resolve #328 explicitly chose this design so a slow main actor cannot stall global keystrokes), so it cannot affect popover behavior. The active accept tap is gated on overlay visibility, which already respects the disable flags viashouldProcessEventsProvider.Greptile Summary
Gates the
FocusTrackerpolling loop onisGloballyEnabledand per-app disabled rules before the expensiveFocusSnapshotResolver.resolveSnapshotAX walk, fixing Apple Calendar's event-creation popover being dismissed by Cotabby's 50-80 ms background polling (#476). The gate is threaded throughFocusTrackingModeland wired inCotabbyAppEnvironmentusing the same checks already applied to the input layer.FocusTracker.swift: AddsisCaptureSuppressedForBundleclosure property; when true, returns an inactive.blockedsnapshot before touching the AX candidate-element tree, with deduped log transitions to avoid log spam.FocusTrackingModel.swift: Forwards the new closure toFocusTrackerwith a{ _ in false }default so existing call sites are unaffected.CotabbyAppEnvironment.swift: Provides the real suppression logic — suppress when globally disabled or when the focused bundle is in the per-app disabled list.Confidence Score: 4/5
Safe to merge for the reported Calendar bug; the fix is well-contained and does not change any existing call sites.
The core suppression gate is correct and the Calendar popover fix is sound. Two small gaps exist: the Chromium OOPIF fallback path still executes a handful of AX queries before the gate fires (harmless for Calendar, could theoretically affect disabled Electron apps), and the suppression-logging deduplication silently skips nil-bundle-ID apps due to a String? != String? nil comparison. Neither affects the reported bug or normal operation.
FocusTracker.swift around the Chromium fallback path (lines 197-207) and the noteCaptureSuppressed logging helper (lines 357-366).
Important Files Changed
isCaptureSuppressedForBundlegate before the expensiveFocusSnapshotResolver.resolveSnapshotcall; includes deduped logging transitions. The Chromium OOPIF fallback path runs a few AX queries before the gate can fire, which is a minor gap for disabled Electron apps.isCaptureSuppressedForBundletoFocusTrackerwith a safe default of{ _ in false }, preserving existing behaviour for callers that don't pass it.isGloballyEnabledthen per-app disabled rules; mirrors the existingshouldProcessEventsProviderlogic. Strong capture ofsuggestionSettingsis intentional and consistent with the existing pattern.Sequence Diagram
sequenceDiagram participant Timer participant FocusTracker participant isCaptureSuppressedForBundle participant SuggestionSettings participant FocusSnapshotResolver participant Downstream Timer->>FocusTracker: handleTimerTick() FocusTracker->>FocusTracker: captureSnapshot() FocusTracker->>FocusTracker: AXHelper.focusedElement() [cheap] alt focused element found FocusTracker->>isCaptureSuppressedForBundle: isCaptureSuppressedForBundle(bundleId) isCaptureSuppressedForBundle->>SuggestionSettings: isGloballyEnabled? SuggestionSettings-->>isCaptureSuppressedForBundle: bool isCaptureSuppressedForBundle->>SuggestionSettings: isApplicationDisabled(bundleId)? SuggestionSettings-->>isCaptureSuppressedForBundle: bool isCaptureSuppressedForBundle-->>FocusTracker: "suppressed = true/false" alt suppressed FocusTracker->>FocusTracker: noteCaptureSuppressed() [log once] FocusTracker-->>Downstream: inactiveCapture(.blocked) else not suppressed FocusTracker->>FocusTracker: noteCaptureResumedIfNeeded() [log once] FocusTracker->>FocusSnapshotResolver: resolveSnapshot() [deep AX walk] FocusSnapshotResolver-->>FocusTracker: FocusSnapshot FocusTracker-->>Downstream: FocusSnapshot (.supported) end endComments Outside Diff (1)
Cotabby/Services/Focus/FocusTracker.swift, line 197-207 (link)resolveChromiumFocusFallback()is invoked beforeisCaptureSuppressedForBundleis checked, so for a disabled Electron/Chromium-family app it still runsAXHelper.isFocused,AXHelper.focusedElement(forApplicationPID:), and (worst case)AXHelper.element(atCocoaPoint:)+AXHelper.nearestEditableon every suppressed tick. The Calendar fix is unaffected becauseBrowserAppDetector.needsWebAccessibilityPrimingreturns false forcom.apple.iCaland the fallback exits immediately — but any Electron app added to the disabled list would still get those intermediate AX attribute reads, which could reproduce the same popover-dismissal symptom for that class of apps. Moving the initial bundle-identity check (or at least skipping the OOPIF fallback path) to beforeresolveChromiumFocusFallbackwould close this gap.Reviews (1): Last reviewed commit: "Stop focus polling from dismissing Calen..." | Re-trigger Greptile