-
-
Notifications
You must be signed in to change notification settings - Fork 243
fix: hide dictation overlay from VoiceOver and fast-path system shortcuts (#401) #402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -493,40 +493,170 @@ 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 *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. | ||
| /// | ||
| /// 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, | ||
| 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 | ||
| } | ||
| } | ||
|
|
||
| /// 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) | ||
| } | ||
| } | ||
|
|
||
| /// 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 | ||
| /// 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<CGEvent>? { | ||
| if let tapRecoveryResult = self.handleTapDisableEvent(type: type, event: event) { | ||
| 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). | ||
| // 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(), | ||
| 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 | ||
| // 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user already has a FluidVoice shortcut such as Cmd+Space or Cmd+ Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| if self.isShortcutCaptureActiveProvider?() ?? false { | ||
| self.resetModifierOnlyShortcutTracking() | ||
| 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: | ||
| // 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 { | ||
|
|
@@ -1693,6 +1823,57 @@ 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 | ||
| } | ||
|
|
||
| /// 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]) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the saved shortcut is a Command system key with an extra modifier (for example Cmd+Shift+Tab or Cmd+Option+Space), keyDown correctly suppresses the fast path and starts the hold/automatic shortcut, but keyUp can arrive after the user has released the extra modifier while Command is still held. In that case this exact
matchescheck no longer sees the configured shortcut, so the keyUp is passed through before the keyCode-only release handlers below can clearis*KeyPressedand stop/finish the recording.Useful? React with 👍 / 👎.