Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 = "<group>"; };
7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalHotkeyManagerSystemShortcutTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
Expand Down Expand Up @@ -103,6 +105,7 @@
7CDB0A262F3C4D5600FB7CAD /* Helpers */,
7CDB0A272F3C4D5600FB7CAD /* Resources */,
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
7CFA0E012F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -258,6 +261,7 @@
files = (
7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */,
7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */,
7CFA0E022F3C4D5600FB7CAD /* GlobalHotkeyManagerSystemShortcutTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
219 changes: 200 additions & 19 deletions Sources/Fluid/Services/GlobalHotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not fast-path releases for saved shortcuts

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 matches check no longer sees the configured shortcut, so the keyUp is passed through before the keyCode-only release handlers below can clear is*KeyPressed and stop/finish the recording.

Useful? React with 👍 / 👎.

}
}

/// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid bypassing saved Command shortcuts

When a user already has a FluidVoice shortcut such as Cmd+Space or Cmd+saved, this early return runs before the configured shortcut checks and passes the event through, so the dictation/cancel/prompt shortcut silently stops firing even though the recorder accepts arbitrary keyDown combinations inhandleShortcutKeyDownEvent` and the matching code below would otherwise handle them. Either reject these combinations when recording/restoring settings or only fast-path them when no configured shortcut matches.

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 {
Expand Down Expand Up @@ -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])
Expand Down
Loading
Loading