From 5fcd88aec0f2949868374b10c96f76470a236b52 Mon Sep 17 00:00:00 2001 From: postoso Date: Tue, 23 Jun 2026 05:46:44 -0400 Subject: [PATCH 1/3] fix: hide dictation overlay from VoiceOver and fast-path system shortcuts Two accessibility/perf fixes for the bottom dictation overlay (issue #401): 1. Accessibility: the four floating NSPanels (overlay + prompt/mode/actions selectors) and the BottomOverlayView SwiftUI body are now hidden from the accessibility tree (setAccessibilityElement(false) + setAccessibilityRole(.unknown), and .accessibilityHidden(true)). VoiceOver no longer announces the overlay or shifts focus away from the user's active app while dictating. 2. Latency: GlobalHotkeyManager's CGEvent-tap callback now fast-paths Command-modified system switching shortcuts (Cmd+Tab, Cmd+Shift+Tab, Cmd+Space, Cmd+`), passing them straight through before the modifier-tracking/shortcut-matching work. The tap fires for every key event system-wide, so this removes perceptible latency from app switching / Spotlight, which compounds when VoiceOver also processes the events. The fast path still records the combo and cancels any pending modifier-only hold start, so a Command-based modifier-only FluidVoice shortcut is not mis-triggered. Adds GlobalHotkeyManagerSystemShortcutTests covering the passthrough decision. The issue's third root cause (DynamicNotchKit's DynamicNotchPanel canBecomeKey/ canBecomeMain) lives in the external DynamicNotchKit package and is out of scope here. Implementation adapted from the patch proposed by @Alshekhi in the issue report. Co-authored-by: Mansour Alshekhi --- Fluid.xcodeproj/project.pbxproj | 4 + .../Fluid/Services/GlobalHotkeyManager.swift | 76 ++++++++++++++++--- Sources/Fluid/Views/BottomOverlayView.swift | 23 ++++++ ...obalHotkeyManagerSystemShortcutTests.swift | 74 ++++++++++++++++++ 4 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 30da8f2d..bd4c873b 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7C3697882ED70F9C005874CE /* DynamicNotchKit */; }; 7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; + 7CFA0E022F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; @@ -33,6 +34,7 @@ 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; + 7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalHotkeyManagerSystemShortcutTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -103,6 +105,7 @@ 7CDB0A262F3C4D5600FB7CAD /* Helpers */, 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, + 7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -258,6 +261,7 @@ files = ( 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, + 7CFA0E022F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 3befca62..c47a8ead 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -493,12 +493,77 @@ final class GlobalHotkeyManager: NSObject { self.runLoopSource = nil } + /// Virtual key codes (`kVK_*`) for the system shortcuts we fast-path in `handleKeyEvent`. + private enum SystemShortcutKeyCode { + static let tab: UInt16 = 48 // kVK_Tab + static let space: UInt16 = 49 // kVK_Space + static let grave: UInt16 = 50 // kVK_ANSI_Grave (`) + } + + /// Returns `true` for Command-modified system shortcuts (Cmd+Tab, Cmd+Shift+Tab, + /// Cmd+Space, Cmd+`) that should be passed straight through without running the + /// hotkey-matching machinery. + /// + /// The session event tap fires for every key event system-wide, so doing the full + /// modifier-tracking and shortcut-matching pass on these high-frequency switching + /// shortcuts adds perceptible latency to app switching and Spotlight. That latency + /// compounds when VoiceOver is also intercepting the same events. None of these + /// shortcuts can be a FluidVoice hotkey (they are reserved by macOS), so passing + /// them through immediately is safe. + nonisolated static func isSystemShortcutPassthrough( + type: CGEventType, + flags: CGEventFlags, + keyCode: UInt16 + ) -> Bool { + guard type == .keyDown || type == .keyUp else { return false } + guard flags.contains(.maskCommand) else { return false } + switch keyCode { + case SystemShortcutKeyCode.tab, SystemShortcutKeyCode.space, SystemShortcutKeyCode.grave: + return true + default: + return false + } + } + + /// When a modifier-only FluidVoice shortcut key (e.g. a Command-based one) is being held and a + /// *different* key is pressed, record the combo and cancel any pending hold-mode start. The user + /// is performing a key combination (e.g. Cmd+Tab), not holding the modifier on its own, so the + /// pending mode start must be cancelled. + private func cancelPendingHoldModeForComboKeyPress() { + guard self.modifierOnlyKeyDown else { return } + self.otherKeyPressedDuringModifier = true + // Cancel any pending hold mode start (user is doing a key combo, not just modifier) + if let pending = self.pendingHoldModeStart { + pending.cancel() + self.pendingHoldModeStart = nil + self.pendingHoldModeType = nil + DebugLogger.shared.info("Another key pressed - cancelled pending hold mode start", source: "GlobalHotkeyManager") + } + } + // swiftlint:disable cyclomatic_complexity function_body_length private func handleKeyEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { if let tapRecoveryResult = self.handleTapDisableEvent(type: type, event: event) { return tapRecoveryResult } + // Fast path: pass system switching shortcuts straight through before any matching + // work, so app switching / Spotlight stay snappy (especially with VoiceOver active). + if Self.isSystemShortcutPassthrough( + type: type, + flags: event.flags, + keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + ) { + // Still record the combo + cancel any pending modifier-only hold start before passing + // through: if a Command-based modifier-only shortcut is held, this Cmd+Tab/Space/` press + // must not look like a clean modifier hold (which would otherwise mis-trigger that mode + // on release or after the hold delay while the user is just switching apps). + if type == .keyDown { + self.cancelPendingHoldModeForComboKeyPress() + } + return Unmanaged.passUnretained(event) + } + if self.isShortcutCaptureActiveProvider?() ?? false { self.resetModifierOnlyShortcutTracking() return Unmanaged.passUnretained(event) @@ -517,16 +582,7 @@ final class GlobalHotkeyManager: NSObject { switch type { case .keyDown: // If a modifier-only shortcut key is being held and this is a different key, mark it - if self.modifierOnlyKeyDown { - self.otherKeyPressedDuringModifier = true - // Cancel any pending hold mode start (user is doing a key combo, not just modifier) - if let pending = self.pendingHoldModeStart { - pending.cancel() - self.pendingHoldModeStart = nil - self.pendingHoldModeType = nil - DebugLogger.shared.info("Another key pressed - cancelled pending hold mode start", source: "GlobalHotkeyManager") - } - } + self.cancelPendingHoldModeForComboKeyPress() // Observe post-transcription edits (do not consume the event). Task { diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 85594c90..a6432bc0 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -256,6 +256,11 @@ final class BottomOverlayWindowController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Keep the overlay panel out of the accessibility tree so VoiceOver does not + // announce it or shift focus away from the user's active app. + panel.setAccessibilityElement(false) + panel.setAccessibilityRole(.unknown) + let contentView = BottomOverlayView() let hostingView = BottomOverlayHostingView(rootView: contentView) @@ -528,6 +533,11 @@ final class BottomOverlayPromptMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Keep the menu panel out of the accessibility tree so VoiceOver does not + // announce it or shift focus away from the user's active app. + panel.setAccessibilityElement(false) + panel.setAccessibilityRole(.unknown) + let contentView = BottomOverlayPromptMenuView( promptMode: self.resolvedPromptMode(), maxWidth: self.menuMaxWidth, @@ -805,6 +815,11 @@ final class BottomOverlayModeMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Keep the menu panel out of the accessibility tree so VoiceOver does not + // announce it or shift focus away from the user's active app. + panel.setAccessibilityElement(false) + panel.setAccessibilityRole(.unknown) + let contentView = BottomOverlayModeMenuView( maxWidth: self.menuMaxWidth, onHoverChanged: { [weak self] hovering in @@ -1069,6 +1084,11 @@ final class BottomOverlayActionsMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none + // Keep the menu panel out of the accessibility tree so VoiceOver does not + // announce it or shift focus away from the user's active app. + panel.setAccessibilityElement(false) + panel.setAccessibilityRole(.unknown) + let contentView = BottomOverlayActionsMenuView( maxWidth: self.menuMaxWidth, onHoverChanged: { [weak self] hovering in @@ -2983,6 +3003,9 @@ struct BottomOverlayView: View { self.isHoveringActionsChip = false self.isHoveringSettingsChip = false } + // The overlay is a non-interactive dictation HUD; hide its SwiftUI content from + // VoiceOver so it is not announced and cannot steal focus from the active app. + .accessibilityHidden(true) // TODO: Add tap-to-expand for command mode history (future enhancement) // .contentShape(Rectangle()) // .onTapGesture { diff --git a/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift new file mode 100644 index 00000000..5da9ed29 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift @@ -0,0 +1,74 @@ +import CoreGraphics +import XCTest + +@testable import FluidVoice_Debug + +/// Regression coverage for the system-shortcut fast path in `GlobalHotkeyManager`. +/// +/// The event tap fires for every key event system-wide, so Command-modified switching +/// shortcuts (Cmd+Tab, Cmd+Shift+Tab, Cmd+Space, Cmd+`) must be passed straight through +/// before any matching work, otherwise app switching / Spotlight pick up perceptible +/// latency (which compounds when VoiceOver is also intercepting the same events). +final class GlobalHotkeyManagerSystemShortcutTests: XCTestCase { + // Virtual key codes (kVK_*). + private let tab: UInt16 = 48 + private let space: UInt16 = 49 + private let grave: UInt16 = 50 + private let escape: UInt16 = 53 + + func testCommandSystemShortcutsArePassedThrough() { + for keyCode in [self.tab, self.space, self.grave] { + XCTAssertTrue( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: .maskCommand, keyCode: keyCode + ), + "Cmd + keyCode \(keyCode) keyDown should be passed straight through" + ) + XCTAssertTrue( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyUp, flags: .maskCommand, keyCode: keyCode + ), + "Cmd + keyCode \(keyCode) keyUp should be passed straight through" + ) + } + } + + func testCommandShiftTabIsPassedThrough() { + // Cmd+Shift+Tab (reverse app switch): extra modifiers must not defeat the fast path. + XCTAssertTrue( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: [.maskCommand, .maskShift], keyCode: self.tab + ) + ) + } + + func testNonCommandKeysAreNotPassedThrough() { + // The same keys without Command must still reach the matching machinery. + for keyCode in [self.tab, self.space, self.grave] { + XCTAssertFalse( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: [], keyCode: keyCode + ) + ) + } + } + + func testUnrelatedCommandShortcutIsNotPassedThrough() { + // e.g. Cmd+Escape is not one of the fast-pathed switching shortcuts. + XCTAssertFalse( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .keyDown, flags: .maskCommand, keyCode: self.escape + ) + ) + } + + func testNonKeyEventsAreNotPassedThrough() { + // flagsChanged carries the Command press itself and must flow through normal + // handling so modifier tracking stays consistent. + XCTAssertFalse( + GlobalHotkeyManager.isSystemShortcutPassthrough( + type: .flagsChanged, flags: .maskCommand, keyCode: self.tab + ) + ) + } +} From c24b43fcfe746b58912615069d0001f71bad0901 Mon Sep 17 00:00:00 2001 From: postoso Date: Tue, 23 Jun 2026 16:14:51 -0400 Subject: [PATCH 2/3] fix: scope overlay a11y-hide to passive HUD and let saved shortcuts win over fast-path (#401) Addresses two over-reach issues found in code review on the #401 fixes: 1. GlobalHotkeyManager: the system-shortcut fast path (Cmd+Tab, Cmd+Space, Cmd+`) ran before the configured-shortcut match and passed the event through unconditionally. The shortcut recorder accepts arbitrary keyDown combinations, so a user can bind one of these combos as a FluidVoice shortcut, which the fast path then silently shadowed. Add shouldFastPathSystemShortcut, which only fast-paths a candidate combo when it does not collide with an enabled configured shortcut (dictation, cancel, prompt assignments, prompt/command/rewrite mode). The latency win is kept for the common non-conflicting case. 2. BottomOverlayView: accessibilityHidden(true) was applied to the entire overlay view and the four floating panels, which removed the interactive controls (mode/prompt/actions/settings chips and the AI-failure Retry/Dismiss buttons) from the accessibility tree. Scope the hiding to only the passive HUD content (transcription preview and waveform/mode row) and drop the panel-level accessibility suppression, so VoiceOver users can still reach the controls while the passive status is not announced. Focus stealing remains prevented by the nonactivating panel style mask. Extends GlobalHotkeyManagerSystemShortcutTests with conflict-aware coverage. --- .../Fluid/Services/GlobalHotkeyManager.swift | 94 +++++++++++++++---- Sources/Fluid/Views/BottomOverlayView.swift | 45 +++++---- ...obalHotkeyManagerSystemShortcutTests.swift | 89 ++++++++++++++++++ 3 files changed, 191 insertions(+), 37 deletions(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index c47a8ead..5fc38398 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -500,16 +500,19 @@ final class GlobalHotkeyManager: NSObject { static let grave: UInt16 = 50 // kVK_ANSI_Grave (`) } - /// Returns `true` for Command-modified system shortcuts (Cmd+Tab, Cmd+Shift+Tab, - /// Cmd+Space, Cmd+`) that should be passed straight through without running the - /// hotkey-matching machinery. + /// Returns `true` for Command-modified *candidate* system switching shortcuts + /// (Cmd+Tab, Cmd+Shift+Tab, Cmd+Space, Cmd+`) that may be passed straight through + /// without running the hotkey-matching machinery. /// /// The session event tap fires for every key event system-wide, so doing the full /// modifier-tracking and shortcut-matching pass on these high-frequency switching /// shortcuts adds perceptible latency to app switching and Spotlight. That latency - /// compounds when VoiceOver is also intercepting the same events. None of these - /// shortcuts can be a FluidVoice hotkey (they are reserved by macOS), so passing - /// them through immediately is safe. + /// compounds when VoiceOver is also intercepting the same events. + /// + /// This decision is *candidate-only*: the shortcut recorder accepts arbitrary keyDown + /// combinations, so a user can bind one of these combos as a FluidVoice shortcut. Use + /// `shouldFastPathSystemShortcut(type:flags:keyCode:configuredShortcuts:)` for the real + /// gate — it suppresses the fast path when the combo collides with a configured shortcut. nonisolated static func isSystemShortcutPassthrough( type: CGEventType, flags: CGEventFlags, @@ -525,6 +528,41 @@ final class GlobalHotkeyManager: NSObject { } } + /// Maps `CGEventFlags` to the `NSEvent.ModifierFlags` used for shortcut matching. + nonisolated static func eventModifierFlags(from flags: CGEventFlags) -> NSEvent.ModifierFlags { + var modifiers: NSEvent.ModifierFlags = [] + if flags.contains(.maskSecondaryFn) { modifiers.insert(.function) } + if flags.contains(.maskCommand) { modifiers.insert(.command) } + if flags.contains(.maskAlternate) { modifiers.insert(.option) } + if flags.contains(.maskControl) { modifiers.insert(.control) } + if flags.contains(.maskShift) { modifiers.insert(.shift) } + return modifiers + } + + /// Returns `true` only when a candidate system switching shortcut should actually be + /// fast-pathed — i.e. it is a candidate (`isSystemShortcutPassthrough`) AND it does not + /// collide with any currently-configured (and enabled) FluidVoice shortcut. + /// + /// If the incoming combo matches a configured FluidVoice shortcut, the fast path is + /// suppressed so the event falls through to normal matching and the user's shortcut still + /// fires. The common (non-conflicting) case keeps the latency win. + nonisolated static func shouldFastPathSystemShortcut( + type: CGEventType, + flags: CGEventFlags, + keyCode: UInt16, + configuredShortcuts: [(shortcut: HotkeyShortcut, isEnabled: Bool)] + ) -> Bool { + guard Self.isSystemShortcutPassthrough(type: type, flags: flags, keyCode: keyCode) else { + return false + } + + let modifiers = Self.eventModifierFlags(from: flags) + return !configuredShortcuts.contains { configured in + configured.isEnabled && + configured.shortcut.matches(keyCode: keyCode, modifiers: modifiers) + } + } + /// When a modifier-only FluidVoice shortcut key (e.g. a Command-based one) is being held and a /// *different* key is pressed, record the combo and cancel any pending hold-mode start. The user /// is performing a key combination (e.g. Cmd+Tab), not holding the modifier on its own, so the @@ -547,12 +585,18 @@ final class GlobalHotkeyManager: NSObject { return tapRecoveryResult } + let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + let flags = event.flags + // Fast path: pass system switching shortcuts straight through before any matching // work, so app switching / Spotlight stay snappy (especially with VoiceOver active). - if Self.isSystemShortcutPassthrough( + // Suppressed when the combo collides with a configured FluidVoice shortcut (e.g. the + // user bound Cmd+Space), so the user's shortcut still fires instead of being shadowed. + if Self.shouldFastPathSystemShortcut( type: type, - flags: event.flags, - keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + flags: flags, + keyCode: keyCode, + configuredShortcuts: self.configuredShortcutsForSystemPassthroughConflict() ) { // Still record the combo + cancel any pending modifier-only hold start before passing // through: if a Command-based modifier-only shortcut is held, this Cmd+Tab/Space/` press @@ -569,15 +613,7 @@ final class GlobalHotkeyManager: NSObject { return Unmanaged.passUnretained(event) } - let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) - let flags = event.flags - - var eventModifiers: NSEvent.ModifierFlags = [] - if flags.contains(.maskSecondaryFn) { eventModifiers.insert(.function) } - if flags.contains(.maskCommand) { eventModifiers.insert(.command) } - if flags.contains(.maskAlternate) { eventModifiers.insert(.option) } - if flags.contains(.maskControl) { eventModifiers.insert(.control) } - if flags.contains(.maskShift) { eventModifiers.insert(.shift) } + let eventModifiers = Self.eventModifierFlags(from: flags) switch type { case .keyDown: @@ -1749,6 +1785,28 @@ final class GlobalHotkeyManager: NSObject { } } + /// The set of currently-configured FluidVoice shortcuts (with their enabled state) that the + /// system-shortcut fast path must not silently shadow. A combo that matches an enabled entry + /// here is NOT fast-pathed, so the user's bound shortcut still fires (see + /// `shouldFastPathSystemShortcut`). The main dictation, cancel, and prompt-assignment + /// shortcuts are always active; the prompt/command/rewrite mode shortcuts honor their + /// enabled flags so a disabled mode shortcut does not block the fast path. + private func configuredShortcutsForSystemPassthroughConflict() -> [(shortcut: HotkeyShortcut, isEnabled: Bool)] { + var shortcuts: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (self.shortcut, true), + (self.promptModeShortcut, self.promptModeShortcutEnabled), + (self.commandModeShortcut, self.commandModeShortcutEnabled), + (self.rewriteModeShortcut, self.rewriteModeShortcutEnabled), + (SettingsStore.shared.cancelRecordingHotkeyShortcut, true), + ] + + shortcuts.append(contentsOf: self.promptShortcutAssignments.map { + (shortcut: $0.shortcut, isEnabled: true) + }) + + return shortcuts + } + private func matchesShortcut(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { let relevantModifiers: NSEvent.ModifierFlags = modifiers.intersection([.function, .command, .option, .control, .shift]) let shortcutModifiers = self.shortcut.modifierFlags.intersection([.function, .command, .option, .control, .shift]) diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index a6432bc0..b43238a8 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -256,10 +256,10 @@ final class BottomOverlayWindowController { panel.hidesOnDeactivate = false panel.animationBehavior = .none - // Keep the overlay panel out of the accessibility tree so VoiceOver does not - // announce it or shift focus away from the user's active app. - panel.setAccessibilityElement(false) - panel.setAccessibilityRole(.unknown) + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do + // NOT suppress the panel's accessibility element, because this panel hosts interactive + // controls (mode/prompt/actions/settings chips, AI-failure Retry/Dismiss). VoiceOver + // visibility of the passive HUD is handled per-subview inside BottomOverlayView. let contentView = BottomOverlayView() let hostingView = BottomOverlayHostingView(rootView: contentView) @@ -533,10 +533,9 @@ final class BottomOverlayPromptMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none - // Keep the menu panel out of the accessibility tree so VoiceOver does not - // announce it or shift focus away from the user's active app. - panel.setAccessibilityElement(false) - panel.setAccessibilityRole(.unknown) + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do NOT + // suppress the panel's accessibility element, because this menu panel hosts interactive + // selection rows that VoiceOver users must be able to reach. let contentView = BottomOverlayPromptMenuView( promptMode: self.resolvedPromptMode(), @@ -815,10 +814,9 @@ final class BottomOverlayModeMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none - // Keep the menu panel out of the accessibility tree so VoiceOver does not - // announce it or shift focus away from the user's active app. - panel.setAccessibilityElement(false) - panel.setAccessibilityRole(.unknown) + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do NOT + // suppress the panel's accessibility element, because this menu panel hosts interactive + // selection rows that VoiceOver users must be able to reach. let contentView = BottomOverlayModeMenuView( maxWidth: self.menuMaxWidth, @@ -1084,10 +1082,9 @@ final class BottomOverlayActionsMenuController { panel.hidesOnDeactivate = false panel.animationBehavior = .none - // Keep the menu panel out of the accessibility tree so VoiceOver does not - // announce it or shift focus away from the user's active app. - panel.setAccessibilityElement(false) - panel.setAccessibilityRole(.unknown) + // Focus stealing is already prevented by the .nonactivatingPanel style mask; we do NOT + // suppress the panel's accessibility element, because this menu panel hosts interactive + // selection rows that VoiceOver users must be able to reach. let contentView = BottomOverlayActionsMenuView( maxWidth: self.menuMaxWidth, @@ -2602,6 +2599,7 @@ struct BottomOverlayView: View { } .buttonStyle(.plain) .help(help) + .accessibilityLabel(Text(help)) } private var aiProcessingFailureView: some View { @@ -2691,6 +2689,8 @@ struct BottomOverlayView: View { } } } + // Passive transcription preview: hidden from VoiceOver. + .accessibilityHidden(true) } } else { Color.clear @@ -2722,6 +2722,8 @@ struct BottomOverlayView: View { .truncationMode(.head) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, max(2, self.transcriptionVerticalPadding - 1)) + // Passive transcription preview: hidden from VoiceOver. + .accessibilityHidden(true) } else { Text(previewText) .font(.system(size: self.layout.transFontSize, weight: .medium)) @@ -2732,6 +2734,8 @@ struct BottomOverlayView: View { .fixedSize(horizontal: false, vertical: true) .frame(width: self.previewMaxWidth, alignment: .leading) .padding(.vertical, self.transcriptionVerticalPadding) + // Passive transcription preview: hidden from VoiceOver. + .accessibilityHidden(true) } } } else if self.shouldShowProcessingStatus { @@ -2814,6 +2818,8 @@ struct BottomOverlayView: View { } } } + // Passive waveform + mode-label row: hidden from VoiceOver. + .accessibilityHidden(true) } .padding(.horizontal, self.layout.hPadding) .padding(.vertical, self.layout.vPadding) @@ -3003,9 +3009,10 @@ struct BottomOverlayView: View { self.isHoveringActionsChip = false self.isHoveringSettingsChip = false } - // The overlay is a non-interactive dictation HUD; hide its SwiftUI content from - // VoiceOver so it is not announced and cannot steal focus from the active app. - .accessibilityHidden(true) + // The interactive controls (mode/prompt/actions/settings chips and the AI-failure + // Retry/Dismiss buttons) stay in the accessibility tree so VoiceOver users can reach + // them; only the passive HUD content (transcription preview, waveform/mode row) is + // hidden from VoiceOver below, so it is not announced or focused while dictating. // TODO: Add tap-to-expand for command mode history (future enhancement) // .contentShape(Rectangle()) // .onTapGesture { diff --git a/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift index 5da9ed29..63bdfefb 100644 --- a/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift @@ -1,3 +1,4 @@ +import AppKit import CoreGraphics import XCTest @@ -9,6 +10,11 @@ import XCTest /// shortcuts (Cmd+Tab, Cmd+Shift+Tab, Cmd+Space, Cmd+`) must be passed straight through /// before any matching work, otherwise app switching / Spotlight pick up perceptible /// latency (which compounds when VoiceOver is also intercepting the same events). +/// +/// The fast path must NOT shadow a user's saved shortcut: the recorder accepts arbitrary +/// keyDown combos, so a user can bind e.g. Cmd+Space as a FluidVoice shortcut. The +/// `shouldFastPathSystemShortcut` tests cover that a configured combo falls through to +/// normal matching while the latency win is preserved for the non-conflicting case. final class GlobalHotkeyManagerSystemShortcutTests: XCTestCase { // Virtual key codes (kVK_*). private let tab: UInt16 = 48 @@ -71,4 +77,87 @@ final class GlobalHotkeyManagerSystemShortcutTests: XCTestCase { ) ) } + + // MARK: - Conflict-aware fast path (shouldFastPathSystemShortcut) + + func testConfiguredCommandSpaceIsNotFastPathed() { + // A user who bound Cmd+Space as a FluidVoice shortcut must keep their shortcut: the + // fast path is suppressed for BOTH keyDown and keyUp so the event reaches normal + // matching (and keyUp release handling) instead of being passed straight through. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.space, modifierFlags: [.command]), true), + ] + + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.space, configuredShortcuts: configured + ), + "A configured Cmd+Space must not be fast-pathed on keyDown" + ) + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, flags: .maskCommand, keyCode: self.space, configuredShortcuts: configured + ), + "A configured Cmd+Space must not be fast-pathed on keyUp" + ) + } + + func testDisabledConfiguredCommandSpaceStillFastPaths() { + // A disabled mode shortcut bound to Cmd+Space must not block the fast path. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.space, modifierFlags: [.command]), false), + ] + + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.space, configuredShortcuts: configured + ) + ) + } + + func testNonConflictingSystemShortcutStillFastPaths() { + // The latency win is preserved for the common case: a configured shortcut on a + // different combo (Cmd+`) must not stop Cmd+Space / Cmd+Tab from fast-pathing. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.grave, modifierFlags: [.command]), true), + ] + + for keyCode in [self.tab, self.space] { + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: keyCode, configuredShortcuts: configured + ), + "Cmd + keyCode \(keyCode) should still fast-path when only Cmd+` is configured" + ) + } + + // With no configured shortcuts at all, every candidate fast-paths. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.space, configuredShortcuts: [] + ) + ) + } + + func testConfiguredNonSystemShortcutIsNeverFastPathed() { + // Even if a configured shortcut matches the incoming combo, a non-candidate combo + // (Cmd+Escape) is never fast-pathed — the fast path is only for switching shortcuts. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.escape, modifierFlags: [.command]), true), + ] + + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, flags: .maskCommand, keyCode: self.escape, configuredShortcuts: configured + ) + ) + } + + func testEventModifierFlagsMapping() { + let modifiers = GlobalHotkeyManager.eventModifierFlags(from: [.maskCommand, .maskShift]) + XCTAssertTrue(modifiers.contains(.command)) + XCTAssertTrue(modifiers.contains(.shift)) + XCTAssertFalse(modifiers.contains(.option)) + XCTAssertFalse(modifiers.contains(.control)) + } } From 25a3d87e3cea762e10f21a5ad24aa194a85d9917 Mon Sep 17 00:00:00 2001 From: postoso Date: Tue, 23 Jun 2026 16:29:20 -0400 Subject: [PATCH 3/3] fix: do not fast-path saved-shortcut releases when modifiers change (#401) Follow-on to the conflict-aware fast path. The fast path used the event's current modifier flags to decide whether a system switching combo collides with a configured FluidVoice shortcut. That is correct on keyDown, but a keyUp can arrive with fewer modifiers than its keyDown: if a saved shortcut is a Command system key plus an extra modifier (e.g. Cmd+Shift+Tab or Cmd+Option+Space) and the user releases the extra modifier first while Command is still held, the keyUp matches as Cmd-only. The flag-based check then no longer sees the configured shortcut, so the release was fast-pathed and the keyUp handlers below never cleared is*KeyPressed or stopped the recording, stranding the recording state. Add hasActivePressNeedingReleaseHandling, which mirrors the keyCode-only, modifier-independent conditions of the keyUp handlers, and a shouldFastPathSystemShortcut overload that suppresses the fast path for any keyUp whose key code has an in-flight press. Releases that need handling now always reach the keyUp handlers regardless of current modifier flags, while genuinely unconfigured system shortcuts keep the latency win. Chose the in-flight-state gate over a modifier-independent keyCode match against configured shortcuts because it ties suppression to the exact invariant being protected (a release the handlers below must finish) and so does not over-suppress the latency win for configured-but-not-currently-held keys. Extends GlobalHotkeyManagerSystemShortcutTests with release-case coverage. --- .../Fluid/Services/GlobalHotkeyManager.swift | 69 +++++++++++++++- ...obalHotkeyManagerSystemShortcutTests.swift | 79 +++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 5fc38398..1f7ba56f 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -563,6 +563,37 @@ final class GlobalHotkeyManager: NSObject { } } + /// Final gate for the fast path, layering the keyUp release-safety check on top of the + /// flag-based collision check. + /// + /// `shouldFastPathSystemShortcut` compares the event's *current* modifier flags against the + /// configured shortcuts, which is correct for keyDown. A keyUp, however, can arrive with fewer + /// modifiers than its keyDown (the user lets go of an extra modifier first, e.g. Shift before + /// Tab in a saved Cmd+Shift+Tab shortcut while Command is still held). The flag-based check then + /// no longer recognizes the configured shortcut, so on its own it would fast-path the release and + /// the keyUp handlers would never clear `is*KeyPressed` or stop the recording. `hasActivePress` + /// reports whether that key code currently has an in-flight press the keyUp handlers must finish; + /// when it does, the release is never fast-pathed regardless of the current modifier flags. + nonisolated static func shouldFastPathSystemShortcut( + type: CGEventType, + flags: CGEventFlags, + keyCode: UInt16, + configuredShortcuts: [(shortcut: HotkeyShortcut, isEnabled: Bool)], + hasActivePress: Bool + ) -> Bool { + guard Self.shouldFastPathSystemShortcut( + type: type, + flags: flags, + keyCode: keyCode, + configuredShortcuts: configuredShortcuts + ) else { + return false + } + // A release with an in-flight press for this key code must reach the keyUp handlers. + if type == .keyUp, hasActivePress { return false } + return true + } + /// When a modifier-only FluidVoice shortcut key (e.g. a Command-based one) is being held and a /// *different* key is pressed, record the combo and cancel any pending hold-mode start. The user /// is performing a key combination (e.g. Cmd+Tab), not holding the modifier on its own, so the @@ -592,11 +623,18 @@ final class GlobalHotkeyManager: NSObject { // work, so app switching / Spotlight stay snappy (especially with VoiceOver active). // Suppressed when the combo collides with a configured FluidVoice shortcut (e.g. the // user bound Cmd+Space), so the user's shortcut still fires instead of being shadowed. + // + // The flag-based collision check is correct for keyDown, but a keyUp can carry different + // modifiers than its keyDown (e.g. Shift released before Tab while Command is still held + // for a saved Cmd+Shift+Tab). So on keyUp we additionally suppress the fast path whenever + // that key code has an in-flight press the keyUp handlers below must finish, so the release + // is never stripped before it can clear is*KeyPressed and stop recording. if Self.shouldFastPathSystemShortcut( type: type, flags: flags, keyCode: keyCode, - configuredShortcuts: self.configuredShortcutsForSystemPassthroughConflict() + configuredShortcuts: self.configuredShortcutsForSystemPassthroughConflict(), + hasActivePress: self.hasActivePressNeedingReleaseHandling(forKeyCode: keyCode) ) { // Still record the combo + cancel any pending modifier-only hold start before passing // through: if a Command-based modifier-only shortcut is held, this Cmd+Tab/Space/` press @@ -1807,6 +1845,35 @@ final class GlobalHotkeyManager: NSObject { return shortcuts } + /// Returns `true` if a configured shortcut whose key code is `keyCode` currently has an + /// in-flight press whose release must be handled by the `keyUp` handlers in `handleKeyEvent`. + /// + /// The flag-based conflict check in `shouldFastPathSystemShortcut` is correct for `keyDown`, + /// but a `keyUp` can arrive with different modifier flags than the `keyDown` did (e.g. the user + /// releases Shift before Tab while Command is still held for a saved Cmd+Shift+Tab shortcut). + /// At that point the flag-based check no longer matches the configured shortcut, so without this + /// guard the release would be fast-pathed and the `keyUp` handlers below would never clear the + /// `is*KeyPressed` state or stop the recording. Those handlers match releases by key code only + /// (independent of modifiers), so this mirrors them: if the matching press flag is set, the + /// release is not eligible for the fast path. + private func hasActivePressNeedingReleaseHandling(forKeyCode keyCode: UInt16) -> Bool { + if self.isKeyPressed, keyCode == self.shortcut.keyCode { return true } + if self.isPromptModeKeyPressed, self.promptModeShortcutEnabled, keyCode == self.promptModeShortcut.keyCode { + return true + } + if self.isCommandModeKeyPressed, self.commandModeShortcutEnabled, keyCode == self.commandModeShortcut.keyCode { + return true + } + if self.isRewriteKeyPressed, self.rewriteModeShortcutEnabled, keyCode == self.rewriteModeShortcut.keyCode { + return true + } + if self.isPromptAssignmentKeyPressed, + self.promptShortcutAssignments.contains(where: { $0.shortcut.keyCode == keyCode }) { + return true + } + return false + } + private func matchesShortcut(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { let relevantModifiers: NSEvent.ModifierFlags = modifiers.intersection([.function, .command, .option, .control, .shift]) let shortcutModifiers = self.shortcut.modifierFlags.intersection([.function, .command, .option, .control, .shift]) diff --git a/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift index 63bdfefb..630e76a1 100644 --- a/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/GlobalHotkeyManagerSystemShortcutTests.swift @@ -160,4 +160,83 @@ final class GlobalHotkeyManagerSystemShortcutTests: XCTestCase { XCTAssertFalse(modifiers.contains(.option)) XCTAssertFalse(modifiers.contains(.control)) } + + // MARK: - Release safety for saved shortcuts (hasActivePress overload) + + func testSavedComboReleaseWithReducedModifiersIsNotFastPathed() { + // Saved shortcut is Cmd+Shift+Tab. The user releases Shift first, so the Tab keyUp + // arrives as Cmd-only. The flag-based collision check no longer sees the configured + // shortcut, but an in-flight press exists, so the release MUST NOT be fast-pathed — + // otherwise the keyUp handlers never clear is*KeyPressed or stop the recording. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.tab, modifierFlags: [.command, .shift]), true), + ] + + // Documents the underlying hazard: the flag-based check alone would fast-path this + // release because the current flags (Cmd only) no longer match Cmd+Shift+Tab. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, flags: .maskCommand, keyCode: self.tab, configuredShortcuts: configured + ), + "Flag-based check is expected to miss the saved combo on a reduced-modifier release" + ) + + // The release-safe overload suppresses it because a press is in flight. + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, + flags: .maskCommand, + keyCode: self.tab, + configuredShortcuts: configured, + hasActivePress: true + ), + "A release with an in-flight press must not be fast-pathed" + ) + } + + func testSavedOptionSpaceReleaseWithReducedModifiersIsNotFastPathed() { + // Saved shortcut is Cmd+Option+Space. Option released first, Space keyUp arrives as + // Cmd-only while a press is in flight: the release must reach the keyUp handlers. + let configured: [(shortcut: HotkeyShortcut, isEnabled: Bool)] = [ + (HotkeyShortcut(keyCode: self.space, modifierFlags: [.command, .option]), true), + ] + + XCTAssertFalse( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, + flags: .maskCommand, + keyCode: self.space, + configuredShortcuts: configured, + hasActivePress: true + ) + ) + } + + func testReleaseWithoutActivePressStillFastPaths() { + // No in-flight press (e.g. a genuine system Cmd+Tab the user never bound): the keyUp + // keeps the latency win and is fast-pathed. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyUp, + flags: .maskCommand, + keyCode: self.tab, + configuredShortcuts: [], + hasActivePress: false + ) + ) + } + + func testKeyDownIgnoresActivePressGuard() { + // The release guard is keyUp-only: a keyDown is unaffected by hasActivePress and still + // fast-paths a non-conflicting candidate. + XCTAssertTrue( + GlobalHotkeyManager.shouldFastPathSystemShortcut( + type: .keyDown, + flags: .maskCommand, + keyCode: self.tab, + configuredShortcuts: [], + hasActivePress: true + ) + ) + } }