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
13 changes: 13 additions & 0 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 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")
return nil
}

if let recordingTarget,
let conflictMessage = self.shortcutConflictMessage(for: newShortcut, target: recordingTarget)
{
Expand Down
23 changes: 23 additions & 0 deletions Sources/Fluid/Models/HotkeyShortcut.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UInt16> = [
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)
Comment on lines +240 to +241

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 Preserve explicit Fn on function-section shortcuts

When the user deliberately holds Globe/Fn and records an F-key, arrow, or navigation key, the flagsChanged path has already put .function into pendingModifierFlags, but this branch strips .function solely because of the key code. As a result Fn+F5 or Fn+Left is treated the same as a bare auto-flagged key and is rejected with “needs a modifier key,” even though the app otherwise treats Fn as a supported modifier (for example Fn+F remains allowed). The guard needs a way to distinguish an explicit Fn press from the auto-injected flag before dropping it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, the record-time half of this is accurate. pendingModifierFlags does know the difference when Fn is physically held (the flagsChanged handler unions .function in), and this guard rejects an explicit Fn+F5 / Fn+Left regardless.

The problem is that match time can't make the same distinction. The global tap derives modifiers purely from CGEventFlags, and macOS sets .maskSecondaryFn on bare presses of every function-section key. So HotkeyShortcut.matches() sees a saved Fn+Left ([.function] + keyCode 123) as identical to a bare Left arrow press. If we preserved the explicit Fn at record time, the saved shortcut would fire on (and consume) every bare Left press system-wide, which is exactly the #556 breakage this PR is closing. For F-keys the collision depends on hardware and settings: Apple keyboards in media-key mode never deliver a bare F5 keyDown, but external keyboards and "use F1, F2, etc. as standard function keys" mode do, so the recorded shortcut would silently behave differently across setups.

Distinguishing a physical Fn hold at match time would mean tracking Fn state in the event tap via flagsChanged (keyCode 63). That gets fragile fast: tap restarts while Fn is held, Globe key remapping, external keyboards that have no Fn key at all. Out of scope for this fix in my opinion.

If F5-style hotkeys are wanted, I think the honest version is the option already flagged in the PR notes: drop the F-key codes from functionSectionKeyCodes so F-keys are recordable again, accepting that they intercept the bare key, rather than recording a modifier the matcher can't honor. Happy to do either depending on maintainer preference.

}
return meaningfulModifiers.isEmpty
}

var normalizedModifierKeyCodes: [UInt16] {
guard !self.isMouseShortcut else { return [] }
let normalized = Self.normalizedModifierKeyCodes(from: self.modifierKeyCodes)
Expand Down
37 changes: 37 additions & 0 deletions Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
Loading