diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 370b6645..d8f1f0f6 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -249,6 +249,7 @@ 52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 52F3962FA9F424576D7DB5B8 /* CotabbyDebugOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */; }; 532283A7651F7E66635F4281 /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */; }; + 533091ED7EB306D103DC94DB /* CalendarAccessibilityCapturePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2BBC9CD727C19DA94DB105 /* CalendarAccessibilityCapturePolicy.swift */; }; 53480635FD54DAF41B1F5047 /* ControlTokenMarkers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC2D6472ACD377FD73A5801 /* ControlTokenMarkers.swift */; }; 53AA9A9D3555A67F8F31DC65 /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CF416511099C6818110F01 /* CompletionRenderModePolicy.swift */; }; 53FB56A095BCF0389DAC0A56 /* SuggestionTextColorCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CE61E74928C221B8BB261C6 /* SuggestionTextColorCodec.swift */; }; @@ -256,6 +257,7 @@ 54BDF0D9C3DC7175555BD0F6 /* LlamaRuntimeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */; }; 54E515A0E75B3902E6497A71 /* EmojiPopularity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27B962C66727776D00069DE /* EmojiPopularity.swift */; }; 5560B54FBBEC80F13BCD2054 /* EmojiPickerPanelLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */; }; + 5580B5FF786B4F5173E01B20 /* CalendarAccessibilityCapturePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2BBC9CD727C19DA94DB105 /* CalendarAccessibilityCapturePolicy.swift */; }; 55D4E6FB63E3475749E61EB3 /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */; }; 55E841977534CBFD8B80E95F /* AXTreeDumpWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44745635AF702C96B4225A2 /* AXTreeDumpWriterTests.swift */; }; 55EDBFF489D4C31276E2A67F /* PermissionHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */; }; @@ -333,6 +335,7 @@ 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 709F365A846B908D953FA92D /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; 7179FB0EC6411166CCD79F6B /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F283E5B403F9FEBFB4BA04A /* CompositionInputModeClassifier.swift */; }; + 7291020840F2164EBB764FAF /* CalendarAccessibilityCaptureGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */; }; 7324B18578C646B1ADFF0C3F /* InsertedTextAdvanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */; }; 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 74422BB837D6A319D12BF981 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */; }; @@ -344,6 +347,7 @@ 76DFC829F2417FB048463285 /* GhostTextPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */; }; 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; 773808D3D88440F0836D0072 /* FileLogHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE98A6C28731BF5C8D434543 /* FileLogHandlerTests.swift */; }; + 77823BCFB29F63615C514927 /* CalendarAccessibilityCaptureGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */; }; 77B57484667F71F7EFA380D1 /* fr-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6DB982BF30B3601F57277776 /* fr-100k.txt */; }; 783BEC91DBC86AF75CEDB269 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; }; 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */; }; @@ -650,6 +654,7 @@ F0556D369F809445D0AC4E9C /* SettingsSearchRankerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C533B73CDDA3685135C460FB /* SettingsSearchRankerTests.swift */; }; F067EA26AC2D007382CE520F /* EmojiPickerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */; }; F08C139B246C1EC7BB435455 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; }; + F279A4064B24FCF714A8CAD4 /* CalendarAccessibilityCapturePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF115FEF7FC96D46C815479 /* CalendarAccessibilityCapturePolicyTests.swift */; }; F28FB178EC507C3D42A6F893 /* SuggestionInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942A53B7C09D1F4EC57239 /* SuggestionInteractionState.swift */; }; F31B343F9C935A5421A526DE /* AXTreeDumpWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27492B04B627DA53BDAD938 /* AXTreeDumpWriter.swift */; }; F41AB06FD117487D7136E896 /* FocusTrackingModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DA0B2D4FE343E321A95C22 /* FocusTrackingModelTests.swift */; }; @@ -710,6 +715,7 @@ 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelAndPresentationValueTests.swift; sourceTree = ""; }; 0397F1DACB094A0F6A66BC0E /* it.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = it.txt; sourceTree = ""; }; 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostSuggestionLayout.swift; sourceTree = ""; }; + 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarAccessibilityCaptureGuard.swift; sourceTree = ""; }; 04D853218B0A77B0CE090828 /* BrowserAppDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAppDetectorTests.swift; sourceTree = ""; }; 04E25414C307A20B6F9F20EC /* FocusSnapshotResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolver.swift; sourceTree = ""; }; 050D929E13BE52E6282B64D2 /* VisualContextStartCoalescerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextStartCoalescerTests.swift; sourceTree = ""; }; @@ -774,6 +780,7 @@ 29CDC8BE5312B9BEFD9B22CB /* SurfaceContextComposerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceContextComposerTests.swift; sourceTree = ""; }; 29ED42C4BDD0C521101AF95E /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAttentionEvaluator.swift; sourceTree = ""; }; + 2AF115FEF7FC96D46C815479 /* CalendarAccessibilityCapturePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarAccessibilityCapturePolicyTests.swift; sourceTree = ""; }; 2B3E5554AAC5D0007CCC61A7 /* LlamaDecodeGateDefaultsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaDecodeGateDefaultsTests.swift; sourceTree = ""; }; 2B7A28471B8526C2693FFF65 /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAttentionEvaluatorTests.swift; sourceTree = ""; }; @@ -1061,6 +1068,7 @@ DDC034BBCBAC5E7989D4C85B /* LlamaSuggestionEngineStreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngineStreamingTests.swift; sourceTree = ""; }; DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactory.swift; sourceTree = ""; }; DDF6A4E9CE93FD53C60E67E3 /* EmojiQueryRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRun.swift; sourceTree = ""; }; + DE2BBC9CD727C19DA94DB105 /* CalendarAccessibilityCapturePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarAccessibilityCapturePolicy.swift; sourceTree = ""; }; DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSubsystemContracts.swift; sourceTree = ""; }; DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformancePaneView.swift; sourceTree = ""; }; DF3A73EB848780061FC162C0 /* SpellingDictionaryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryPicker.swift; sourceTree = ""; }; @@ -1274,6 +1282,7 @@ children = ( CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */, B27492B04B627DA53BDAD938 /* AXTreeDumpWriter.swift */, + 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */, 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */, 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */, B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */, @@ -1435,6 +1444,7 @@ 04D853218B0A77B0CE090828 /* BrowserAppDetectorTests.swift */, 1F761083EA5465023D82B5F4 /* BrowserDomainTests.swift */, 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */, + 2AF115FEF7FC96D46C815479 /* CalendarAccessibilityCapturePolicyTests.swift */, 8EA827D6A2A54DF4BAD56405 /* CaretLinePositionTests.swift */, 421FD16E18622824E038DFB4 /* CaretRunPlacementTests.swift */, EFD89799BB82AF7A92559AEB /* ClipboardContentDistillerTests.swift */, @@ -1697,6 +1707,7 @@ AD8025E4A296845FC53E660D /* BrowserDomain.swift */, AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */, 01D84EED1A6A711F39DEA18F /* BundleVersion.swift */, + DE2BBC9CD727C19DA94DB105 /* CalendarAccessibilityCapturePolicy.swift */, E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */, D77364C1AF183EF1C0A4074D /* CaretLinePosition.swift */, 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */, @@ -2004,6 +2015,8 @@ 2BE029A192E82E795490DC7F /* BrowserDomain.swift in Sources */, 06A310C087B460289B5ACCFE /* BundleVersion.swift in Sources */, 07E50A9ECCE55072DA311F8F /* BundledRuntimeLocator.swift in Sources */, + 7291020840F2164EBB764FAF /* CalendarAccessibilityCaptureGuard.swift in Sources */, + 5580B5FF786B4F5173E01B20 /* CalendarAccessibilityCapturePolicy.swift in Sources */, 7C72A2D76E8BA38ADD523CF6 /* CaretGeometrySelector.swift in Sources */, 06CFA03207FF92EB272A66F2 /* CaretLinePosition.swift in Sources */, CCB8D287A5FF3863B9DE9246 /* ChromiumAccessibilityEnabler.swift in Sources */, @@ -2250,6 +2263,8 @@ 7D9C3D733CE7633FB12A35BE /* BrowserDomain.swift in Sources */, D46BCACE71169BF8403948CE /* BundleVersion.swift in Sources */, 3CBBC3BFAC0DC8952EE24EF7 /* BundledRuntimeLocator.swift in Sources */, + 77823BCFB29F63615C514927 /* CalendarAccessibilityCaptureGuard.swift in Sources */, + 533091ED7EB306D103DC94DB /* CalendarAccessibilityCapturePolicy.swift in Sources */, 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */, 543D71217CA218426E9638BF /* CaretLinePosition.swift in Sources */, D800277BE3296DE2FB8198A8 /* ChromiumAccessibilityEnabler.swift in Sources */, @@ -2487,6 +2502,7 @@ 5C0D3B6012C7412001BE3773 /* BrowserAppDetectorTests.swift in Sources */, C3B6C8B9DE20A71C65D390DA /* BrowserDomainTests.swift in Sources */, 58AC3193D846FDE88513377D /* BundledRuntimeLocatorTests.swift in Sources */, + F279A4064B24FCF714A8CAD4 /* CalendarAccessibilityCapturePolicyTests.swift in Sources */, 62FADA407797998742502DD9 /* CaretLinePositionTests.swift in Sources */, 418055EF945C17BAEFAB89ED /* CaretRunPlacementTests.swift in Sources */, 8865B95FE84198D70390DF80 /* ClipboardContentDistillerTests.swift in Sources */, diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 06d4d389..da6d15d4 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -16,6 +16,8 @@ final class CotabbyAppEnvironment { let modelDownloadManager: ModelDownloadManager let focusModel: FocusTrackingModel let inputMonitor: InputMonitor + /// Temporarily pauses Calendar AX traversal only while its date/time editor is active. + let calendarAccessibilityCaptureGuard: CalendarAccessibilityCaptureGuard let appUpdateManager: AppUpdateManager let permissionGuidanceController: PermissionGuidanceController let suggestionSettings: SuggestionSettingsModel @@ -60,6 +62,10 @@ final class CotabbyAppEnvironment { permissionProvider: { permissionManager.inputMonitoringGranted }, suppressionController: suppressionController ) + let calendarAccessibilityCaptureGuard = CalendarAccessibilityCaptureGuard() + inputMonitor.onPointerDown = { [weak calendarAccessibilityCaptureGuard] point in + calendarAccessibilityCaptureGuard?.handlePointerDown(atAccessibilityPoint: point) + } inputMonitor.acceptanceKeyCodeProvider = { suggestionSettings.acceptanceKeyCode } inputMonitor.acceptanceKeyModifiersProvider = { suggestionSettings.acceptanceKeyModifiers } inputMonitor.fullAcceptanceKeyCodeProvider = { suggestionSettings.fullAcceptanceKeyCode } @@ -69,11 +75,9 @@ 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. + // Stop the deep AX walk when Cotabby is disabled for the focused app or while Calendar's + // fragile date/time editor is active. The latter is interaction-scoped: Calendar text fields + // still resolve normally, unlike the old app-wide suppression workaround for #544. let focusModel = FocusTrackingModel( permissionProvider: { permissionManager.accessibilityGranted }, ignoredBundleIdentifier: Bundle.main.bundleIdentifier, @@ -86,7 +90,9 @@ final class CotabbyAppEnvironment { suggestionSettings.isApplicationDisabled(bundleIdentifier: bundleIdentifier) { return true } - return false + return calendarAccessibilityCaptureGuard.shouldSuppressCapture( + for: bundleIdentifier + ) }, publishesPollingEvents: FocusDebugOverlayController.isEnabled ) @@ -96,6 +102,11 @@ final class CotabbyAppEnvironment { inputMonitor.shouldProcessEventsProvider = { [weak focusModel] in guard suggestionSettings.isGloballyEnabled else { return false } guard let snapshot = focusModel?.snapshot else { return true } + if calendarAccessibilityCaptureGuard.shouldSuppressCapture( + for: snapshot.bundleIdentifier + ) { + return false + } if TerminalAppDetector.isTerminal(bundleIdentifier: snapshot.bundleIdentifier) { return false } if let bundleID = snapshot.bundleIdentifier, suggestionSettings.isApplicationDisabled(bundleIdentifier: bundleID) { @@ -268,6 +279,7 @@ final class CotabbyAppEnvironment { self.modelDownloadManager = modelDownloadManager self.focusModel = focusModel self.inputMonitor = inputMonitor + self.calendarAccessibilityCaptureGuard = calendarAccessibilityCaptureGuard self.appUpdateManager = appUpdateManager self.permissionGuidanceController = permissionGuidanceController self.suggestionSettings = suggestionSettings diff --git a/Cotabby/Services/Focus/CalendarAccessibilityCaptureGuard.swift b/Cotabby/Services/Focus/CalendarAccessibilityCaptureGuard.swift new file mode 100644 index 00000000..daf1331d --- /dev/null +++ b/Cotabby/Services/Focus/CalendarAccessibilityCaptureGuard.swift @@ -0,0 +1,71 @@ +import AppKit +import ApplicationServices +import Foundation +import Logging + +/// Owns the short-lived Calendar date/time interaction state used by `FocusTracker`'s capture gate. +/// +/// `InputMonitor` supplies global pointer-down coordinates. This service first checks the frontmost +/// bundle (so normal clicks pay no AX cost), hit-tests only Calendar, and reduces the clicked AX +/// element through `CalendarAccessibilityCapturePolicy`. `CotabbyAppEnvironment` owns one instance +/// for the app lifetime and the focus model reads its current state before any candidate-tree walk. +@MainActor +final class CalendarAccessibilityCaptureGuard { + private var isDateTimeInteractionActive = false + + /// Updates the guard from a Quartz/global screen point (top-left origin). + func handlePointerDown(atAccessibilityPoint point: CGPoint) { + guard NSWorkspace.shared.frontmostApplication?.bundleIdentifier + == CalendarAccessibilityCapturePolicy.calendarBundleIdentifier else { + updateSuppression(false) + return + } + + // A click that resolves to no AX element (e.g. empty Calendar canvas) leaves the current + // state untouched rather than resuming. The date-picker popup is the fragile surface this + // guard protects, and forcing a resume on every unresolved click risks dropping suppression + // mid-edit. Active suppression still ends the moment the pointer lands on a real text field + // or another app (handled below and in the policy), so a normal editing flow cannot strand it. + guard let target = AXHelper.element(atAccessibilityPoint: point) else { + return + } + + let targetApplication = AXHelper.owningApplication(of: target) + let nextState = CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: isDateTimeInteractionActive, + targetBundleIdentifier: targetApplication?.bundleIdentifier, + targetRole: AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: target), + targetIdentifier: AXHelper.accessibilityIdentifier(of: target) + ) + updateSuppression(nextState) + } + + /// `FocusTracker` calls this after its cheap focused-element lookup and before resolver traversal. + /// + /// This both reads and updates state: observing any non-Calendar bundle clears an active + /// suppression. The mutation lives here (rather than in a separate step) because an app switch can + /// happen by keyboard with no pointer event, so this poll is the only signal that the user left + /// Calendar. The `updateSuppression` guard makes the repeated calls on the focus/key-event hot + /// paths cheap no-ops once the state has settled. + 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 + } + + private func updateSuppression(_ shouldSuppress: Bool) { + guard shouldSuppress != isDateTimeInteractionActive else { + return + } + isDateTimeInteractionActive = shouldSuppress + if shouldSuppress { + CotabbyLogger.focus.info("Paused Calendar AX capture for date/time editing") + } else { + CotabbyLogger.focus.info("Resumed Calendar AX capture after date/time editing") + } + } +} diff --git a/Cotabby/Services/Input/InputMonitor.swift b/Cotabby/Services/Input/InputMonitor.swift index e76d06bf..51b538aa 100644 --- a/Cotabby/Services/Input/InputMonitor.swift +++ b/Cotabby/Services/Input/InputMonitor.swift @@ -3,9 +3,9 @@ import Foundation import Logging /// File overview: -/// Owns the global keyboard event taps used to detect typing, navigation, dismissal keys, -/// and `Tab` acceptance. This is the boundary between raw CGEvents and Cotabby's smaller -/// input-event vocabulary. +/// Owns the global input event taps used to detect typing, navigation, dismissal keys, `Tab` +/// acceptance, and compatibility-relevant pointer presses. This is the boundary between raw +/// CGEvents and Cotabby's smaller input-event vocabulary. /// /// `CapturedInputEvent` now lives in `Models/InputModels.swift` so the rest of the app can depend /// on the semantic event type without importing this event-tap implementation. @@ -40,9 +40,9 @@ enum InputMonitorAcceptTapDecision: Equatable { /// Installs two taps: /// - A steady-state `.listenOnly` observer at the head of the chain. Listen-only taps do not gate -/// event delivery on the callback's return, so a slow main actor cannot stall global keystrokes -/// in unrelated apps (DaVinci Resolve's Spacebar play/pause is the canonical victim of an active -/// tap here — see issue #328). It handles ordinary typing, navigation, and dismissal events. +/// event delivery on the callback's return, so a slow main actor cannot stall input in unrelated +/// apps (DaVinci Resolve's Spacebar play/pause is the canonical victim of an active tap here — see +/// issue #328). It handles ordinary typing, navigation, dismissal, and pointer observation. /// - A narrow `.defaultTap` accept tap at the tail, installed only while a suggestion is visible. /// This is the only path that consumes events, so it also owns acceptance side effects. Keeping /// insertion and consumption in the same callback prevents the coordinator from hiding the overlay @@ -52,6 +52,11 @@ final class InputMonitor { var onEvent: ((CapturedInputEvent) -> Bool)? var onSuppressedSyntheticInput: (() -> Void)? + /// Reports physical pointer-down locations from the existing listen-only tap. The Calendar AX + /// compatibility guard uses this to pause focus-tree reads before Calendar handles its fragile + /// date/time disclosure click. No pointer event is consumed or modified. + var onPointerDown: (@MainActor (CGPoint) -> Void)? + /// While an emoji capture session is active, the picker controller decides per key whether the /// active tap should swallow it (navigation, Return/Tab, Escape) or let it reach the field /// (query characters). `.notHandled` means no capture is active, so the accept tap falls through @@ -241,6 +246,8 @@ final class InputMonitor { } let mask = (1 << CGEventType.keyDown.rawValue) + | (1 << CGEventType.leftMouseDown.rawValue) + | (1 << CGEventType.rightMouseDown.rawValue) let callback: CGEventTapCallBack = { _, type, event, userInfo in guard let userInfo else { return Unmanaged.passUnretained(event) @@ -501,11 +508,24 @@ final class InputMonitor { ) return Unmanaged.passUnretained(event) + case .leftMouseDown, .rightMouseDown: + handleObserverPointerDown(at: event.location) + return Unmanaged.passUnretained(event) + default: return Unmanaged.passUnretained(event) } } + /// Testable pointer path for the listen-only observer tap. + /// + /// Production enters through `handleObserverTap`; accepting a value here lets tests verify that + /// pointer coordinates reach compatibility guards without allocating a CoreGraphics event in the + /// app-hosted test process. + func handleObserverPointerDown(at point: CGPoint) { + onPointerDown?(point) + } + /// Testable observer path for semantic key snapshots. /// /// Production still enters through `handleObserverTap`; this method exists so tests can verify diff --git a/Cotabby/Support/AXHelper.swift b/Cotabby/Support/AXHelper.swift index 78921d3a..c650300c 100644 --- a/Cotabby/Support/AXHelper.swift +++ b/Cotabby/Support/AXHelper.swift @@ -550,10 +550,20 @@ enum AXHelper { guard let primaryHeight = NSScreen.screens.first?.frame.height else { return nil } - let axX = Float(point.x) - let axY = Float(primaryHeight - point.y) + let accessibilityPoint = CGPoint(x: point.x, y: primaryHeight - point.y) + return element(atAccessibilityPoint: accessibilityPoint) + } + + /// Hit-tests an Accessibility element from Quartz/CGEvent global coordinates (top-left origin). + /// + /// Global input taps already report points in AX screen space, so converting those points through + /// Cocoa would introduce an unnecessary second coordinate flip. Calendar's interaction guard uses + /// this overload directly from the listen-only pointer event callback. + static func element(atAccessibilityPoint point: CGPoint) -> AXUIElement? { var element: AXUIElement? - guard AXUIElementCopyElementAtPosition(systemWideElement(), axX, axY, &element) == .success else { + guard AXUIElementCopyElementAtPosition( + systemWideElement(), Float(point.x), Float(point.y), &element + ) == .success else { return nil } return element diff --git a/Cotabby/Support/CalendarAccessibilityCapturePolicy.swift b/Cotabby/Support/CalendarAccessibilityCapturePolicy.swift new file mode 100644 index 00000000..3e169029 --- /dev/null +++ b/Cotabby/Support/CalendarAccessibilityCapturePolicy.swift @@ -0,0 +1,62 @@ +import ApplicationServices +import Foundation + +/// Pure state transition for Apple Calendar's fragile date/time editor. +/// +/// Calendar keeps the previously focused text field as first responder while its date/time section +/// is open. Reading that stale field's broader Accessibility neighborhood makes Calendar collapse +/// the section and move focus to another editor row. The policy therefore enters suppression only +/// for date/time controls and leaves it only when the pointer reaches a real text input or another +/// application. Keeping this rule pure makes the compatibility behavior testable without a live AX +/// tree; `CalendarAccessibilityCaptureGuard` owns the OS hit-testing boundary. +enum CalendarAccessibilityCapturePolicy { + static let calendarBundleIdentifier = "com.apple.iCal" + + private static let dateTimeControlIdentifiers: Set = [ + "date-time-button", + "start-datepicker", + "start-timepicker", + "end-datepicker", + "end-timepicker" + ] + + private static let editableTextRoles: Set = [ + kAXTextFieldRole as String, + kAXTextAreaRole as String, + "AXSearchField", + kAXComboBoxRole as String + ] + + /// Computes the guard's next state for one physical pointer-down target. + /// + /// While suppression is active, unknown Calendar controls keep it active. Date picker popups + /// expose several private button roles with unstable identifiers, so treating an unknown click + /// as "resume" would reintroduce the bug on the user's next date selection. A click into any + /// editable text role is an explicit safe boundary and resumes normal autocomplete capture. + static func shouldSuppressCapture( + currentlySuppressed: Bool, + targetBundleIdentifier: String?, + targetRole: String?, + targetIdentifier: String? + ) -> Bool { + // Only a positively-identified non-Calendar app is an explicit "resume" signal. A nil bundle + // id means the AX owner lookup failed, not that the click left Calendar (the guard already + // confirmed Calendar is frontmost), so treating nil as "resume" could drop suppression + // mid-edit and reintroduce the collapse bug. Fall through and let the role/identifier checks + // — or the held state — decide instead. + if let targetBundleIdentifier, targetBundleIdentifier != calendarBundleIdentifier { + return false + } + + if targetRole == "AXDateTimeArea" + || targetIdentifier.map(dateTimeControlIdentifiers.contains) == true { + return true + } + + if targetRole.map(editableTextRoles.contains) == true { + return false + } + + return currentlySuppressed + } +} diff --git a/CotabbyTests/CalendarAccessibilityCapturePolicyTests.swift b/CotabbyTests/CalendarAccessibilityCapturePolicyTests.swift new file mode 100644 index 00000000..24938e0a --- /dev/null +++ b/CotabbyTests/CalendarAccessibilityCapturePolicyTests.swift @@ -0,0 +1,97 @@ +import ApplicationServices +import XCTest +@testable import Cotabby + +final class CalendarAccessibilityCapturePolicyTests: XCTestCase { + func testDateTimeDisclosureStartsSuppression() { + XCTAssertTrue( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: false, + targetBundleIdentifier: "com.apple.iCal", + targetRole: kAXButtonRole as String, + targetIdentifier: "date-time-button" + ) + ) + } + + func testDateTimeAreaKeepsSuppressionActive() { + XCTAssertTrue( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: true, + targetBundleIdentifier: "com.apple.iCal", + targetRole: "AXDateTimeArea", + targetIdentifier: "start-datepicker" + ) + ) + } + + func testUnknownCalendarPickerControlKeepsExistingSuppression() { + XCTAssertTrue( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: true, + targetBundleIdentifier: "com.apple.iCal", + targetRole: kAXButtonRole as String, + targetIdentifier: nil + ) + ) + } + + func testCalendarTextFieldResumesCapture() { + XCTAssertFalse( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: true, + targetBundleIdentifier: "com.apple.iCal", + targetRole: kAXTextFieldRole as String, + targetIdentifier: "title-field" + ) + ) + } + + func testAnotherApplicationClearsSuppression() { + XCTAssertFalse( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: true, + targetBundleIdentifier: "com.apple.TextEdit", + targetRole: kAXTextAreaRole as String, + targetIdentifier: nil + ) + ) + } + + func testUnresolvedOwnerBundleKeepsExistingSuppression() { + // A nil bundle id means the AX owner lookup failed while Calendar is frontmost (the guard + // already gated on that), not that the click left Calendar. Suppression must persist rather + // than spuriously resume mid-edit and reintroduce the collapse bug. + XCTAssertTrue( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: true, + targetBundleIdentifier: nil, + targetRole: kAXButtonRole as String, + targetIdentifier: nil + ) + ) + } + + func testUnresolvedOwnerBundleStillResumesOnTextField() { + // An editable role is an explicit safe boundary even when the owning bundle can't be read. + XCTAssertFalse( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: true, + targetBundleIdentifier: nil, + targetRole: kAXTextFieldRole as String, + targetIdentifier: nil + ) + ) + } + + func testOrdinaryCalendarClickDoesNotStartSuppression() { + XCTAssertFalse( + CalendarAccessibilityCapturePolicy.shouldSuppressCapture( + currentlySuppressed: false, + targetBundleIdentifier: "com.apple.iCal", + targetRole: kAXButtonRole as String, + targetIdentifier: "today-button" + ) + ) + } +} diff --git a/CotabbyTests/InputMonitorTests.swift b/CotabbyTests/InputMonitorTests.swift index 6babd9f6..84698f27 100644 --- a/CotabbyTests/InputMonitorTests.swift +++ b/CotabbyTests/InputMonitorTests.swift @@ -13,6 +13,18 @@ final class InputMonitorTests: XCTestCase { /// process lifetime keeps the tests focused on routing behavior instead of deinit mechanics. @MainActor private static var retainedMonitors: [InputMonitor] = [] + func test_observerTapForwardsPointerLocationWithoutConsumingIt() { + runOnMainActor { + let monitor = makeMonitor() + var observedPoint: CGPoint? + monitor.onPointerDown = { observedPoint = $0 } + + monitor.handleObserverPointerDown(at: CGPoint(x: 321, y: 654)) + + XCTAssertEqual(observedPoint, CGPoint(x: 321, y: 654)) + } + } + func test_observerTapIgnoresPrimaryAcceptKeyWhenConsumingTapOwnsIt() { runOnMainActor { let monitor = makeMonitor()