Skip to content

fix: suppress calendar accessibility capture#745

Open
akramj13 wants to merge 1 commit into
FuJacob:mainfrom
akramj13:codex/fix-calendar-app-blocking
Open

fix: suppress calendar accessibility capture#745
akramj13 wants to merge 1 commit into
FuJacob:mainfrom
akramj13:codex/fix-calendar-app-blocking

Conversation

@akramj13

@akramj13 akramj13 commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add a Calendar-specific Accessibility capture guard that detects interactions with date/time controls before Calendar handles the click.
  • Temporarily pause Cotabby's focus-tree traversal and keyboard processing while Calendar's fragile date/time editor is active, preventing the editor from collapsing or shifting focus.
  • Resume capture when the user clicks an editable Calendar text field or switches to another application, preserving autocomplete in ordinary Calendar fields.
  • Extend the existing listen-only input tap to observe left/right pointer-down locations without consuming or modifying mouse events.
  • Add unit coverage for Calendar suppression state transitions and pointer-location forwarding.

Validation

xcodebuild -project Cotabby.xcodeproj -scheme 'Cotabby Dev' \
  -configuration Debug -destination 'platform=macOS' \
  -derivedDataPath build/DerivedData \
  CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= build
# ** BUILD SUCCEEDED **

The Calendar interaction itself has not yet been verified end-to-end in this session.

Linked issues

Fixes #544

Risk / rollout notes

  • The observer tap now listens for mouse-down events in addition to key-down events, but remains listen-only and never consumes pointer input.
  • While suppression is active, unknown Calendar picker controls intentionally keep it active; capture resets on a recognized editable text field or application switch.

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@MainActor owner of the live suppression flag; updates on every pointer-down via onPointerDown and is queried by the focus model's capture gate and the input monitor's event gate.
  • AXHelper — refactored to expose a separate element(atAccessibilityPoint:) overload for Quartz-origin coordinates from CGEvent taps, keeping the existing Cocoa-origin atScreenPoint variant 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

Filename Overview
Cotabby/Services/Focus/CalendarAccessibilityCaptureGuard.swift New guard class that owns the Calendar suppression state; has two logic concerns: silent stale-state when AX hit-test fails, and mutation embedded in the query method shouldSuppressCapture.
Cotabby/Support/CalendarAccessibilityCapturePolicy.swift New pure state-transition enum; clean logic, good identifier/role sets, and well-covered by unit tests.
Cotabby/Services/Input/InputMonitor.swift Adds left/right mouse-down to the listen-only tap mask and routes them through a testable handleObserverPointerDown; tap is added to the main run loop with MainActor.assumeIsolated, so threading is correct.
Cotabby/Support/AXHelper.swift Correct refactor: extracts an atAccessibilityPoint overload that accepts Quartz/AX-origin coordinates directly, keeping the original atScreenPoint as a wrapper that converts from Cocoa coordinates.
Cotabby/App/Core/CotabbyAppEnvironment.swift Wires up the guard, onPointerDown (weak capture), and two closures that call shouldSuppressCapture with strong captures; the guard is stored as a property so lifetimes are safe.
CotabbyTests/CalendarAccessibilityCapturePolicyTests.swift Good coverage of all six policy state transitions; tests are clear and isolated.
CotabbyTests/InputMonitorTests.swift Adds a focused test that the listen-only tap forwards pointer coordinates without consuming the event; straightforward and correct.

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
Loading
%%{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 normally
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix: suppress calendar accessibility cap..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 CalendarAccessibilityCapturePolicy plus a CalendarAccessibilityCaptureGuard service 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.

Comment on lines +1 to +3
import AppKit
import Foundation
import Logging
Comment on lines +42 to +44
guard targetBundleIdentifier == calendarBundleIdentifier else {
return false
}
Comment on lines +23 to +25
guard let target = AXHelper.element(atAccessibilityPoint: point) else {
return
}

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 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.

Fix in Codex Fix in Claude Code

Comment on lines +38 to +46
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
}

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 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.

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] Apple Calendar bug

2 participants