From a0856369a9f0287b3b68afcab79611e36c10c63e Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 31 May 2026 11:27:17 -0700 Subject: [PATCH] Stop focus polling from dismissing Calendar's event-detail popover 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. --- Cotabby/App/Core/CotabbyAppEnvironment.swift | 13 +++++ Cotabby/Models/FocusTrackingModel.swift | 4 +- Cotabby/Services/Focus/FocusTracker.swift | 50 ++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 0387c27b..da644623 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -64,9 +64,22 @@ final class CotabbyAppEnvironment { inputMonitor.onGlobalToggleHotkey = { [weak suggestionSettings] in suggestionSettings?.toggleGloballyEnabled() } + // Stop the deep AX walk when Cotabby is disabled for the focused app. Without this the + // focus poll keeps enumerating the frontmost app's AX attributes every 50-80ms even after + // the user toggles Cotabby off, which can dismiss transient popovers in apps like Calendar + // (#476). Gating here also makes the "I disabled it but the bug remained" symptom go away: + // the disable toggles now actually stop touching the focused app. let focusModel = FocusTrackingModel( permissionProvider: { permissionManager.accessibilityGranted }, ignoredBundleIdentifier: Bundle.main.bundleIdentifier, + isCaptureSuppressedForBundle: { bundleIdentifier in + guard suggestionSettings.isGloballyEnabled else { return true } + if let bundleIdentifier, + suggestionSettings.isApplicationDisabled(bundleIdentifier: bundleIdentifier) { + return true + } + return false + }, publishesPollingEvents: FocusDebugOverlayController.isEnabled ) // The snapshot is poll-based, so after a fast app switch the closure may briefly diff --git a/Cotabby/Models/FocusTrackingModel.swift b/Cotabby/Models/FocusTrackingModel.swift index 0c92bc57..e15cebb8 100644 --- a/Cotabby/Models/FocusTrackingModel.swift +++ b/Cotabby/Models/FocusTrackingModel.swift @@ -20,12 +20,14 @@ final class FocusTrackingModel: ObservableObject { init( permissionProvider: @escaping @MainActor () -> Bool, ignoredBundleIdentifier: String?, + isCaptureSuppressedForBundle: @escaping @MainActor (String?) -> Bool = { _ in false }, publishesPollingEvents: Bool = false ) { self.ignoredBundleIdentifier = ignoredBundleIdentifier tracker = FocusTracker( permissionProvider: permissionProvider, - ignoredBundleIdentifier: ignoredBundleIdentifier + ignoredBundleIdentifier: ignoredBundleIdentifier, + isCaptureSuppressedForBundle: isCaptureSuppressedForBundle ) snapshot = tracker.snapshot latestExternalApplication = tracker.snapshot.externalApplicationIdentity( diff --git a/Cotabby/Services/Focus/FocusTracker.swift b/Cotabby/Services/Focus/FocusTracker.swift index b1f7fa2b..c9878657 100644 --- a/Cotabby/Services/Focus/FocusTracker.swift +++ b/Cotabby/Services/Focus/FocusTracker.swift @@ -23,6 +23,13 @@ final class FocusTracker { private var pollInterval: TimeInterval private let permissionProvider: @MainActor () -> Bool private let ignoredBundleIdentifier: String? + /// Returns true when the focused app's bundle should NOT have its AX tree deep-walked. The + /// gate runs after the cheap system-wide focused-element query but before the expensive + /// candidate-elements walk in `FocusSnapshotResolver`. macOS popovers (Calendar's event-detail + /// popover, in particular) self-dismiss when AX attribute enumeration runs against them, so + /// disabling Cotabby globally or for a specific app must actually stop the walk, not just + /// stop generating suggestions on top of it (#476). + private let isCaptureSuppressedForBundle: @MainActor (String?) -> Bool private let snapshotResolver: FocusSnapshotResolver private var timer: Timer? @@ -45,6 +52,10 @@ final class FocusTracker { private var chromiumHitTestCache: (element: AXUIElement, pid: pid_t)? private var lastChromeProbeSignature: String? + // Last bundle identifier we logged as suppressed. Used to emit one log line per + // suppression transition instead of one per 50-80ms poll tick. + private var lastSuppressedBundleIdentifier: String? + // Wakes Chromium/Electron web-accessibility trees so their web text becomes readable. Priming // is what turns a Chrome renderer from "AX-unaware" (omnibox-only) into a tree the focus // queries and hit-test fallback can actually resolve. @@ -54,11 +65,13 @@ final class FocusTracker { pollInterval: TimeInterval = 0.08, permissionProvider: @escaping @MainActor () -> Bool, ignoredBundleIdentifier: String?, + isCaptureSuppressedForBundle: @escaping @MainActor (String?) -> Bool = { _ in false }, snapshotResolver: FocusSnapshotResolver? = nil ) { self.pollInterval = pollInterval self.permissionProvider = permissionProvider self.ignoredBundleIdentifier = ignoredBundleIdentifier + self.isCaptureSuppressedForBundle = isCaptureSuppressedForBundle // Default resolver construction must happen inside the actor-isolated initializer body. // Swift evaluates default parameter expressions before entering the `@MainActor` context. self.snapshotResolver = snapshotResolver ?? FocusSnapshotResolver() @@ -217,6 +230,21 @@ final class FocusTracker { ) } + // Bail before any AX deep-walk when Cotabby is disabled for the focused app. Stops + // `FocusSnapshotResolver.resolveSnapshot` from enumerating attributes on transient popover + // windows (Calendar's event-detail popover dismisses itself when its AX tree is read out + // from underneath it — #476). The cheap `AXHelper.focusedElement()` query above is fine to + // run; only the candidate-elements walk hits the popover. + if isCaptureSuppressedForBundle(application.bundleIdentifier) { + noteCaptureSuppressed(for: application) + return inactiveCapture( + applicationName: application.localizedName ?? "?", + bundleIdentifier: application.bundleIdentifier, + capability: .blocked("Cotabby is disabled for this app.") + ) + } + noteCaptureResumedIfNeeded() + let resolveStart = ContinuousClock.now let firstPassSnapshot = snapshotResolver.resolveSnapshot( focusedElement: focusedElement, @@ -323,6 +351,28 @@ final class FocusTracker { CotabbyLogger.focus.debug("\(line)") } + /// Emits one log line per suppression transition. The gate is consulted on every poll tick, so + /// without dedupe this would write ~12-20 lines/second for as long as the user stays in the + /// disabled app. + 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))") + } + + private func noteCaptureResumedIfNeeded() { + guard lastSuppressedBundleIdentifier != nil else { + return + } + lastSuppressedBundleIdentifier = nil + CotabbyLogger.focus.info("Focus capture resumed") + } + private func inactiveCapture( applicationName: String, bundleIdentifier: String?,