From 1f65d9ee07183d48f2f09df4bbd7da2ea3b28e5c Mon Sep 17 00:00:00 2001 From: Akshay Soni Date: Wed, 8 Jul 2026 13:14:47 -0700 Subject: [PATCH 1/2] fix: require a modifier key for keyboard shortcut recording A bare key (e.g. F, keyCode 3) currently records as a valid global hotkey and then intercepts that key system-wide, breaking normal typing in every app with no UI way to remove it. Add the same "needs a modifier key" guard the mouse-click recorder already uses, exempting the cancel shortcut since it legitimately defaults to a bare Escape. Closes #556 --- Sources/Fluid/ContentView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 26e617e6..664aa9ec 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -757,6 +757,19 @@ struct ContentView: View { let newShortcut = HotkeyShortcut(keyCode: keyCode, modifierFlags: self.pendingModifierFlags.union(eventModifiers)) DebugLogger.shared.debug("NSEvent monitor: Recording new shortcut: \(newShortcut.displayString)", source: "ContentView") + // A bare key with no modifiers (e.g. "F") registers as a global hotkey and would + // intercept that key everywhere, breaking normal typing in every app. Require at + // least one modifier for global keyboard shortcuts, mirroring the guard the mouse + // recorder already applies (isUnmodifiedLeftOrRightClick "needs a modifier key"). + // The cancel shortcut is exempt: it only applies while recording is active and + // legitimately defaults to a bare Escape. + if let recordingTarget, recordingTarget != .cancel, newShortcut.relevantModifierFlags.isEmpty { + self.shortcutRecordingMessage = "\(newShortcut.displayString) needs a modifier key" + self.resetPendingShortcutState() + DebugLogger.shared.debug("NSEvent monitor: Rejected modifier-less keyboard shortcut while recording", source: "ContentView") + return nil + } + if let recordingTarget, let conflictMessage = self.shortcutConflictMessage(for: newShortcut, target: recordingTarget) { From bfb1a2b5f3a04289f72404e031c78046f23e5c7c Mon Sep 17 00:00:00 2001 From: Akshay Soni Date: Wed, 8 Jul 2026 13:34:15 -0700 Subject: [PATCH 2/2] fix: also reject bare function-section keys in shortcut recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS auto-sets the .function modifier flag on key events for arrows, F-keys, and other function-section keys even when Fn is not held, so the relevantModifierFlags.isEmpty guard let a bare arrow/F-key/nav key through and it would still be intercepted system-wide (the tap maps .maskSecondaryFn to .function, so every bare press of that key matched). Move the predicate onto HotkeyShortcut as isUnmodifiedKeyboardKey — the keyboard analogue of isUnmodifiedLeftOrRightClick — which ignores the auto-injected .function flag for function-section key codes while still honoring a genuine Fn chord on typing keys (e.g. Fn+F), and excludes modifier-only shortcuts. Cover it with unit tests in HotkeyShortcutTests. Co-Authored-By: Claude Fable 5 --- Sources/Fluid/ContentView.swift | 14 +++---- Sources/Fluid/Models/HotkeyShortcut.swift | 23 ++++++++++++ .../HotkeyShortcutTests.swift | 37 +++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 664aa9ec..807d8f69 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -757,13 +757,13 @@ struct ContentView: View { let newShortcut = HotkeyShortcut(keyCode: keyCode, modifierFlags: self.pendingModifierFlags.union(eventModifiers)) DebugLogger.shared.debug("NSEvent monitor: Recording new shortcut: \(newShortcut.displayString)", source: "ContentView") - // A bare key with no modifiers (e.g. "F") registers as a global hotkey and would - // intercept that key everywhere, breaking normal typing in every app. Require at - // least one modifier for global keyboard shortcuts, mirroring the guard the mouse - // recorder already applies (isUnmodifiedLeftOrRightClick "needs a modifier key"). - // The cancel shortcut is exempt: it only applies while recording is active and - // legitimately defaults to a bare Escape. - if let recordingTarget, recordingTarget != .cancel, newShortcut.relevantModifierFlags.isEmpty { + // A bare key with no real modifier (e.g. "F", or an arrow key — whose events carry + // an auto-injected .function flag) registers as a global hotkey and would intercept + // that key everywhere, breaking normal typing in every app. Require a modifier, + // mirroring the guard the mouse recorder already applies (isUnmodifiedLeftOrRightClick + // "needs a modifier key"). The cancel shortcut is exempt: it is only consumed while + // dictation is active and legitimately defaults to a bare Escape. + if let recordingTarget, recordingTarget != .cancel, newShortcut.isUnmodifiedKeyboardKey { self.shortcutRecordingMessage = "\(newShortcut.displayString) needs a modifier key" self.resetPendingShortcutState() DebugLogger.shared.debug("NSEvent monitor: Rejected modifier-less keyboard shortcut while recording", source: "ContentView") diff --git a/Sources/Fluid/Models/HotkeyShortcut.swift b/Sources/Fluid/Models/HotkeyShortcut.swift index 17d39e00..71f0f6c6 100644 --- a/Sources/Fluid/Models/HotkeyShortcut.swift +++ b/Sources/Fluid/Models/HotkeyShortcut.swift @@ -220,6 +220,29 @@ extension HotkeyShortcut { return (mouseButton == 0 || mouseButton == 1) && self.relevantModifierFlags.isEmpty } + /// Key codes in the keyboard's function section (F-keys, navigation keys, arrows). + /// macOS auto-sets the `.function` modifier flag on key events for these keys even + /// when the Fn key is not held, so that flag alone does not indicate a real modifier. + static let functionSectionKeyCodes: Set = [ + 122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, // F1–F12 + 105, 107, 113, 106, 64, 79, 80, 90, // F13–F20 + 114, 115, 116, 117, 119, 121, // Help, Home, Page Up, Forward Delete, End, Page Down + 123, 124, 125, 126, // Left, Right, Down, Up + ] + + /// True for a keyboard shortcut that would fire on a bare key press with no real + /// modifier held — the keyboard analogue of `isUnmodifiedLeftOrRightClick`. The + /// auto-injected `.function` flag on function-section keys does not count as a + /// modifier; modifier-only shortcuts (e.g. Right ⌥) are never bare. + var isUnmodifiedKeyboardKey: Bool { + guard !self.isMouseShortcut, self.modifierTriggerFlag == nil else { return false } + var meaningfulModifiers = self.relevantModifierFlags + if Self.functionSectionKeyCodes.contains(self.keyCode) { + meaningfulModifiers.subtract(.function) + } + return meaningfulModifiers.isEmpty + } + var normalizedModifierKeyCodes: [UInt16] { guard !self.isMouseShortcut else { return [] } let normalized = Self.normalizedModifierKeyCodes(from: self.modifierKeyCodes) diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index b7e0f8ee..fe8e57df 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -60,6 +60,43 @@ final class HotkeyShortcutTests: XCTestCase { XCTAssertTrue(modifiedLeftClick.matchesMouse(button: 0, modifiers: [.control])) } + func testUnmodifiedKeyboardKeysRequireModifiers() { + let bareLetter = HotkeyShortcut(keyCode: 3, modifierFlags: []) // F + let commandLetter = HotkeyShortcut(keyCode: 3, modifierFlags: [.command]) // ⌘F + let fnLetter = HotkeyShortcut(keyCode: 3, modifierFlags: [.function]) // real Fn + F + let bareSpace = HotkeyShortcut(keyCode: 49, modifierFlags: []) // Space + + XCTAssertTrue(bareLetter.isUnmodifiedKeyboardKey) + XCTAssertFalse(commandLetter.isUnmodifiedKeyboardKey) + XCTAssertFalse(fnLetter.isUnmodifiedKeyboardKey) + XCTAssertTrue(bareSpace.isUnmodifiedKeyboardKey) + } + + func testAutoInjectedFunctionFlagDoesNotCountAsModifier() { + // macOS sets .function automatically on events for function-section keys, so a + // bare arrow, F-key, or navigation key press arrives carrying that flag even + // though no modifier is held. + let bareLeftArrow = HotkeyShortcut(keyCode: 123, modifierFlags: [.function]) + let bareF5 = HotkeyShortcut(keyCode: 96, modifierFlags: [.function]) + let bareHome = HotkeyShortcut(keyCode: 115, modifierFlags: [.function]) + let shiftedArrow = HotkeyShortcut(keyCode: 123, modifierFlags: [.function, .shift]) + + XCTAssertTrue(bareLeftArrow.isUnmodifiedKeyboardKey) + XCTAssertTrue(bareF5.isUnmodifiedKeyboardKey) + XCTAssertTrue(bareHome.isUnmodifiedKeyboardKey) + XCTAssertFalse(shiftedArrow.isUnmodifiedKeyboardKey) + } + + func testModifierOnlyAndMouseShortcutsAreNeverUnmodifiedKeyboardKeys() { + let optionOnly = HotkeyShortcut(keyCode: 61, modifierFlags: []) // Right ⌥ + let fnOnly = HotkeyShortcut(keyCode: 63, modifierFlags: []) // Fn + let mouseClick = HotkeyShortcut(mouseButton: 0, modifierFlags: []) + + XCTAssertFalse(optionOnly.isUnmodifiedKeyboardKey) + XCTAssertFalse(fnOnly.isUnmodifiedKeyboardKey) + XCTAssertFalse(mouseClick.isUnmodifiedKeyboardKey) + } + func testMouseShortcutDisplayIncludesModifiers() { let shortcut = HotkeyShortcut(mouseButton: 0, modifierFlags: [.control, .shift])