Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Cotabby/Models/FocusTrackingModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
50 changes: 50 additions & 0 deletions Cotabby/Services/Focus/FocusTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))")
}
Comment on lines +357 to +366
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Suppression logging silently drops when bundleIdentifier is nil

lastSuppressedBundleIdentifier is String?. When application.bundleIdentifier is nil, the comparison lastSuppressedBundleIdentifier != bundleIdentifier evaluates as nil != nilfalse, 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.

Fix in Codex Fix in Claude Code


private func noteCaptureResumedIfNeeded() {
guard lastSuppressedBundleIdentifier != nil else {
return
}
lastSuppressedBundleIdentifier = nil
CotabbyLogger.focus.info("Focus capture resumed")
}

private func inactiveCapture(
applicationName: String,
bundleIdentifier: String?,
Expand Down