Skip to content

Stop focus polling from dismissing Calendar's event popover (closes #476)#483

Merged
FuJacob merged 1 commit into
mainfrom
fix/calendar-popover-disable-gates
Jun 1, 2026
Merged

Stop focus polling from dismissing Calendar's event popover (closes #476)#483
FuJacob merged 1 commit into
mainfrom
fix/calendar-popover-disable-gates

Conversation

@FuJacob

@FuJacob FuJacob commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

FocusTracker polls the frontmost app's AX tree every 50-80ms and runs 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 the cause of #476.

The polling loop is ungated: it does the deep AX walk regardless of isGloballyEnabled and disabledAppRules. 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

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build
# ** BUILD SUCCEEDED **

xcodebuild test -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
# ** TEST SUCCEEDED ** 578 tests, 4 skipped, 0 failures

swiftlint lint --quiet Cotabby/Services/Focus/FocusTracker.swift \
  Cotabby/Models/FocusTrackingModel.swift \
  Cotabby/App/Core/CotabbyAppEnvironment.swift
# exit 0

Manual repro the reporter (or a maintainer) can run:

  1. With Cotabby up and Calendar in disabledAppRules, create a new event in Calendar. The detail popover should now stay open and date/time/alerts should be editable.
  2. Tail ~/Library/Logs/Cotabby/cotabby.jsonl and filter for category=focus. While Calendar is focused you should see one Focus capture suppressed for Calendar (com.apple.iCal) line on entry and one Focus capture resumed line when switching to another app. If those lines do not appear, the gate is not engaging.
  3. Sanity: TextEdit / Notes should still get ghost text as before (the gate only triggers when the focused app is disabled).

Linked issues

Closes #476.

Risk / rollout notes

  • New plumbing flows through 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.
  • On a suppressed tick the tracker now returns .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.
  • Per-tick log line is deduped to one entry per suppression transition (entering / leaving the disabled-app state). No log spam under normal use.
  • I did NOT change InputMonitor lifecycle. After reading the file the steady-state observer tap is .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 via shouldProcessEventsProvider.

Greptile Summary

Gates the FocusTracker polling loop on isGloballyEnabled and per-app disabled rules before the expensive FocusSnapshotResolver.resolveSnapshot AX walk, fixing Apple Calendar's event-creation popover being dismissed by Cotabby's 50-80 ms background polling (#476). The gate is threaded through FocusTrackingModel and wired in CotabbyAppEnvironment using the same checks already applied to the input layer.

  • FocusTracker.swift: Adds isCaptureSuppressedForBundle closure property; when true, returns an inactive .blocked snapshot before touching the AX candidate-element tree, with deduped log transitions to avoid log spam.
  • FocusTrackingModel.swift: Forwards the new closure to FocusTracker with 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

Filename Overview
Cotabby/Services/Focus/FocusTracker.swift Core change: adds isCaptureSuppressedForBundle gate before the expensive FocusSnapshotResolver.resolveSnapshot call; 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.
Cotabby/Models/FocusTrackingModel.swift Pass-through plumbing: forwards isCaptureSuppressedForBundle to FocusTracker with a safe default of { _ in false }, preserving existing behaviour for callers that don't pass it.
Cotabby/App/Core/CotabbyAppEnvironment.swift Provides the suppression closure, correctly checking isGloballyEnabled then per-app disabled rules; mirrors the existing shouldProcessEventsProvider logic. Strong capture of suggestionSettings is 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
    end
Loading

Comments Outside Diff (1)

  1. Cotabby/Services/Focus/FocusTracker.swift, line 197-207 (link)

    P2 Chromium fallback AX queries bypass the suppression gate

    resolveChromiumFocusFallback() is invoked before isCaptureSuppressedForBundle is checked, so for a disabled Electron/Chromium-family app it still runs AXHelper.isFocused, AXHelper.focusedElement(forApplicationPID:), and (worst case) AXHelper.element(atCocoaPoint:) + AXHelper.nearestEditable on every suppressed tick. The Calendar fix is unaffected because BrowserAppDetector.needsWebAccessibilityPriming returns false for com.apple.iCal and 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 before resolveChromiumFocusFallback would close this gap.

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Stop focus polling from dismissing Calen..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

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.
@FuJacob FuJacob marked this pull request as ready for review June 1, 2026 04:13
@FuJacob FuJacob merged commit aaeec00 into main Jun 1, 2026
4 checks passed
Comment on lines +357 to +366
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))")
}

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Breaks info window in Calendar

1 participant