fix: suppress calendar accessibility capture#745
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a Calendar-specific suppression path that pauses Cotabby’s Accessibility capture and input processing while Apple Calendar’s fragile date/time editor is active, aiming to prevent Calendar from collapsing or shifting focus during date/time edits (Fixes #544).
Changes:
- Extend the existing listen-only input tap to also observe left/right mouse-down events and forward pointer locations to a compatibility guard.
- Add a pure
CalendarAccessibilityCapturePolicyplus aCalendarAccessibilityCaptureGuardservice to manage suppression state transitions. - Add unit tests covering pointer-location forwarding and Calendar suppression policy state transitions.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| CotabbyTests/InputMonitorTests.swift | Adds unit coverage that the observer tap forwards pointer-down locations. |
| CotabbyTests/CalendarAccessibilityCapturePolicyTests.swift | Adds unit tests for Calendar suppression policy transitions. |
| Cotabby/Support/CalendarAccessibilityCapturePolicy.swift | Implements pure state transition logic for Calendar AX capture suppression. |
| Cotabby/Support/AXHelper.swift | Adds an AX hit-test overload for already-AX/global (top-left origin) points. |
| Cotabby/Services/Input/InputMonitor.swift | Observes mouse-down events in the listen-only tap and forwards locations via onPointerDown. |
| Cotabby/Services/Focus/CalendarAccessibilityCaptureGuard.swift | Adds the Calendar interaction guard that hit-tests pointer targets and updates suppression state. |
| Cotabby/App/Core/CotabbyAppEnvironment.swift | Wires the guard into InputMonitor and uses it to gate focus traversal and input processing. |
| Cotabby.xcodeproj/project.pbxproj | Adds new source/test files to the Xcode project/targets. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import AppKit | ||
| import Foundation | ||
| import Logging |
| guard targetBundleIdentifier == calendarBundleIdentifier else { | ||
| return false | ||
| } |
| guard let target = AXHelper.element(atAccessibilityPoint: point) else { | ||
| return | ||
| } |
There was a problem hiding this comment.
Stale suppression on AX element miss
When AXHelper.element(atAccessibilityPoint:) returns nil (click in an empty Calendar canvas area or on a control AXUIElementCopyElementAtPosition can't resolve), the guard returns early without touching isDateTimeInteractionActive. If suppression is already true when this happens — e.g., the user opened the date picker, then clicked an empty area adjacent to it — suppression is left permanently active. The only escape paths are clicking a resolvable text field, or switching apps. Consider calling updateSuppression(false) on the nil path (treating an unresolvable click as a safe boundary), or at minimum documenting the intentional sticky behavior.
| func shouldSuppressCapture(for bundleIdentifier: String?) -> Bool { | ||
| guard bundleIdentifier == CalendarAccessibilityCapturePolicy.calendarBundleIdentifier else { | ||
| // App switches can happen by keyboard with no pointer event. Clearing here prevents a | ||
| // stale Calendar interaction from suppressing capture when the user later switches back. | ||
| updateSuppression(false) | ||
| return false | ||
| } | ||
| return isDateTimeInteractionActive | ||
| } |
There was a problem hiding this comment.
Mutation hidden inside a query method
shouldSuppressCapture(for:) clears isDateTimeInteractionActive as a side effect whenever it is called with any non-Calendar bundle ID. In CotabbyAppEnvironment this method is called from two independent closures — the focus model's isCaptureSuppressedForBundle gate (on every AX poll, for every focused app) and shouldProcessEventsProvider (on every key event). The high-frequency polling means the "clear on app switch" path fires continuously for all non-Calendar apps, not just on the exact transition event. More importantly, if any future caller queries this method while Calendar is the frontmost app for a diagnostic or logging purpose, it would silently reset in-progress suppression mid-interaction. Renaming this to checkAndUpdateSuppression(for:), or splitting the mutation out into a separate clearIfNotCalendar(bundleIdentifier:) step, would make the contract explicit.
Summary
Validation
The Calendar interaction itself has not yet been verified end-to-end in this session.
Linked issues
Fixes #544
Risk / rollout notes
Greptile Summary
This PR introduces a Calendar-specific Accessibility capture guard that suppresses Cotabby's AX focus-tree traversal while Calendar's fragile date/time editor is open, fixing issue #544 where reading a stale focused element caused Calendar to collapse its date picker. The listen-only input tap is extended to observe left/right mouse-down events (without consuming them) so the guard can update suppression state before any AX walk fires.
CalendarAccessibilityCapturePolicy— pure state machine classifying pointer-down targets as date/time controls (enter suppression), editable text fields (leave suppression), or unknown Calendar controls (preserve current state); fully unit-tested.CalendarAccessibilityCaptureGuard—@MainActorowner of the live suppression flag; updates on every pointer-down viaonPointerDownand is queried by the focus model's capture gate and the input monitor's event gate.AXHelper— refactored to expose a separateelement(atAccessibilityPoint:)overload for Quartz-origin coordinates from CGEvent taps, keeping the existing Cocoa-originatScreenPointvariant as a thin wrapper.Confidence Score: 4/5
The change is additive and safe to merge; the listen-only tap never consumes events and suppression is scoped narrowly to Calendar.
The core policy logic is thoroughly unit-tested and the threading model is correct. Two areas in CalendarAccessibilityCaptureGuard warrant follow-up: the nil-element early-return leaves suppression sticky with no self-healing path, and shouldSuppressCapture mutates state on every read from a non-Calendar caller, which could surprise future diagnostic code.
CalendarAccessibilityCaptureGuard.swift — the nil-element early-return path and the query-with-side-effect pattern in shouldSuppressCapture.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant User participant CGEventTap as CGEvent Tap (main RL) participant InputMonitor participant Guard as CalendarAccessibilityCaptureGuard participant Policy as CalendarAccessibilityCapturePolicy participant AXHelper participant FocusModel as FocusTrackingModel User->>CGEventTap: leftMouseDown (Calendar date-time btn) CGEventTap->>InputMonitor: handleObserverTap (MainActor.assumeIsolated) InputMonitor->>Guard: onPointerDown(point) Guard->>Guard: "check frontmostApp == com.apple.iCal" Guard->>AXHelper: element(atAccessibilityPoint: point) AXHelper-->>Guard: AXUIElement Guard->>AXHelper: owningApplication(of: element) Guard->>Policy: shouldSuppressCapture(currentlySuppressed, bundleID, role, identifier) Policy-->>Guard: true (date-time control) Guard->>Guard: "isDateTimeInteractionActive = true" Note over FocusModel: Next AX poll (50-80 ms) FocusModel->>Guard: shouldSuppressCapture(for: com.apple.iCal) Guard-->>FocusModel: true, skip AX tree walk User->>CGEventTap: leftMouseDown (Calendar text field) CGEventTap->>InputMonitor: handleObserverTap InputMonitor->>Guard: onPointerDown(point) Guard->>Policy: shouldSuppressCapture role AXTextField Policy-->>Guard: false (editable text role) Guard->>Guard: "isDateTimeInteractionActive = false" FocusModel->>Guard: shouldSuppressCapture(for: com.apple.iCal) Guard-->>FocusModel: false, AX tree walk proceeds normally%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant User participant CGEventTap as CGEvent Tap (main RL) participant InputMonitor participant Guard as CalendarAccessibilityCaptureGuard participant Policy as CalendarAccessibilityCapturePolicy participant AXHelper participant FocusModel as FocusTrackingModel User->>CGEventTap: leftMouseDown (Calendar date-time btn) CGEventTap->>InputMonitor: handleObserverTap (MainActor.assumeIsolated) InputMonitor->>Guard: onPointerDown(point) Guard->>Guard: "check frontmostApp == com.apple.iCal" Guard->>AXHelper: element(atAccessibilityPoint: point) AXHelper-->>Guard: AXUIElement Guard->>AXHelper: owningApplication(of: element) Guard->>Policy: shouldSuppressCapture(currentlySuppressed, bundleID, role, identifier) Policy-->>Guard: true (date-time control) Guard->>Guard: "isDateTimeInteractionActive = true" Note over FocusModel: Next AX poll (50-80 ms) FocusModel->>Guard: shouldSuppressCapture(for: com.apple.iCal) Guard-->>FocusModel: true, skip AX tree walk User->>CGEventTap: leftMouseDown (Calendar text field) CGEventTap->>InputMonitor: handleObserverTap InputMonitor->>Guard: onPointerDown(point) Guard->>Policy: shouldSuppressCapture role AXTextField Policy-->>Guard: false (editable text role) Guard->>Guard: "isDateTimeInteractionActive = false" FocusModel->>Guard: shouldSuppressCapture(for: com.apple.iCal) Guard-->>FocusModel: false, AX tree walk proceeds normallyReviews (1): Last reviewed commit: "fix: suppress calendar accessibility cap..." | Re-trigger Greptile