From ba0c7393efa9cbeef3d2d00c4c3b51edbc16035b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Jul 2026 15:39:59 -0400 Subject: [PATCH] Prevent printable hotkeys from leaking on stop Centralize hotkey event interception so the key press that stops a double-tap locked recording is consumed instead of reaching the active application. Add regression coverage for a backslash recording hotkey while leaving unrelated printable keys untouched. --- .changeset/6d28c968.md | 5 ++ Hex/Features/App/AppFeature.swift | 4 +- .../Transcription/TranscriptionFeature.swift | 28 +++++------ .../Logic/HotKeyEventInterception.swift | 24 ++++++++++ .../HexCore/Logic/HotKeyProcessor.swift | 6 +-- HexCore/Sources/HexCore/Models/HotKey.swift | 4 ++ .../HexCoreTests/HotKeyProcessorTests.swift | 48 +++++++++++++++++++ 7 files changed, 94 insertions(+), 25 deletions(-) create mode 100644 .changeset/6d28c968.md create mode 100644 HexCore/Sources/HexCore/Logic/HotKeyEventInterception.swift diff --git a/.changeset/6d28c968.md b/.changeset/6d28c968.md new file mode 100644 index 0000000..c29c914 --- /dev/null +++ b/.changeset/6d28c968.md @@ -0,0 +1,5 @@ +--- +"hex-app": patch +--- + +Prevent printable double-tap hotkeys from leaking their stop key into the active app diff --git a/Hex/Features/App/AppFeature.swift b/Hex/Features/App/AppFeature.swift index cbeaeec..0ab151d 100644 --- a/Hex/Features/App/AppFeature.swift +++ b/Hex/Features/App/AppFeature.swift @@ -188,9 +188,7 @@ struct AppFeature { // Check if this matches the paste last transcript hotkey guard let pasteHotkey = hexSettings.pasteLastTranscriptHotkey, - let key = keyEvent.key, - key == pasteHotkey.key, - keyEvent.modifiers.matchesExactly(pasteHotkey.modifiers) else { + pasteHotkey.matches(keyEvent) else { return false } diff --git a/Hex/Features/Transcription/TranscriptionFeature.swift b/Hex/Features/Transcription/TranscriptionFeature.swift index 4e2d200..6b31c70 100644 --- a/Hex/Features/Transcription/TranscriptionFeature.swift +++ b/Hex/Features/Transcription/TranscriptionFeature.swift @@ -191,35 +191,29 @@ private extension TranscriptionFeature { } // Process the key event - switch hotKeyProcessor.process(keyEvent: keyEvent) { + let output = hotKeyProcessor.process(keyEvent: keyEvent) + switch output { case .startRecording: Task { await send(.hotKeyPressed) } - // If the hotkey is purely modifiers, return false to keep it from interfering with normal usage - // But if useDoubleTapOnly is true, always intercept the key - return useDoubleTapOnly || keyEvent.key != nil case .stopRecording: Task { await send(.hotKeyReleased) } - return false // or `true` if you want to intercept case .cancel: Task { await send(.cancel) } - return true case .discard: Task { await send(.discard) } - return false // Don't intercept - let the key chord reach other apps - case .none: - // If we detect repeated same chord, maybe intercept. - if let pressedKey = keyEvent.key, - pressedKey == hotKeyProcessor.hotkey.key, - keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers - { - return true - } - return false - } + case nil: + break + } + return HotKeyEventInterception.shouldIntercept( + output: output, + keyEvent: keyEvent, + hotkey: hotKeyProcessor.hotkey, + useDoubleTapOnly: useDoubleTapOnly + ) case .mouseClick: // Process mouse click - for modifier-only hotkeys, this may cancel/discard diff --git a/HexCore/Sources/HexCore/Logic/HotKeyEventInterception.swift b/HexCore/Sources/HexCore/Logic/HotKeyEventInterception.swift new file mode 100644 index 0000000..7f7b29b --- /dev/null +++ b/HexCore/Sources/HexCore/Logic/HotKeyEventInterception.swift @@ -0,0 +1,24 @@ +public enum HotKeyEventInterception { + public static func shouldIntercept( + output: HotKeyProcessor.Output?, + keyEvent: KeyEvent, + hotkey: HotKey, + useDoubleTapOnly: Bool + ) -> Bool { + switch output { + case .startRecording: + return useDoubleTapOnly || keyEvent.key != nil + case .stopRecording: + // A locked recording stops on key-down. Consume the matching hotkey so + // it is not inserted into the destination app, but preserve unrelated + // keys that can also stop a short accidental recording. + return hotkey.matches(keyEvent) + case .cancel: + return true + case .discard: + return false + case nil: + return keyEvent.key != nil && hotkey.matches(keyEvent) + } + } +} diff --git a/HexCore/Sources/HexCore/Logic/HotKeyProcessor.swift b/HexCore/Sources/HexCore/Logic/HotKeyProcessor.swift index e6a44a5..53f2013 100644 --- a/HexCore/Sources/HexCore/Logic/HotKeyProcessor.swift +++ b/HexCore/Sources/HexCore/Logic/HotKeyProcessor.swift @@ -444,11 +444,7 @@ extension HotKeyProcessor { /// - Parameter e: The keyboard event to check /// - Returns: True if event matches hotkey configuration private func chordMatchesHotkey(_ e: KeyEvent) -> Bool { - if hotkey.key != nil { - return e.key == hotkey.key && e.modifiers.matchesExactly(hotkey.modifiers) - } else { - return e.key == nil && e.modifiers.matchesExactly(hotkey.modifiers) - } + hotkey.matches(e) } /// Checks if keyboard event contains extra keys/modifiers that should trigger dirty state. diff --git a/HexCore/Sources/HexCore/Models/HotKey.swift b/HexCore/Sources/HexCore/Models/HotKey.swift index aa2385a..da87ed9 100644 --- a/HexCore/Sources/HexCore/Models/HotKey.swift +++ b/HexCore/Sources/HexCore/Models/HotKey.swift @@ -320,6 +320,10 @@ public struct HotKey: Codable, Equatable, Sendable { self.key = key self.modifiers = modifiers } + + public func matches(_ event: KeyEvent) -> Bool { + event.key == key && event.modifiers.matchesExactly(modifiers) + } } extension Key { diff --git a/HexCore/Tests/HexCoreTests/HotKeyProcessorTests.swift b/HexCore/Tests/HexCoreTests/HotKeyProcessorTests.swift index 4efc36b..c990f2f 100644 --- a/HexCore/Tests/HexCoreTests/HotKeyProcessorTests.swift +++ b/HexCore/Tests/HexCoreTests/HotKeyProcessorTests.swift @@ -35,6 +35,54 @@ struct HotKeyProcessorTests { ) } + @Test + func printableKeyThatStopsDoubleTapLockIsIntercepted() { + let hotkey = HotKey(key: .backslash, modifiers: []) + var processor = withDependencies { + $0.date.now = Date(timeIntervalSince1970: 0) + } operation: { + HotKeyProcessor(hotkey: hotkey) + } + + func process(at time: TimeInterval, key: Key?) -> (HotKeyProcessor.Output?, Bool) { + let keyEvent = KeyEvent(key: key, modifiers: []) + let output = withDependencies { + $0.date.now = Date(timeIntervalSince1970: time) + } operation: { + processor.process(keyEvent: keyEvent) + } + let intercepted = HotKeyEventInterception.shouldIntercept( + output: output, + keyEvent: keyEvent, + hotkey: hotkey, + useDoubleTapOnly: false + ) + return (output, intercepted) + } + + _ = process(at: 0, key: .backslash) + _ = process(at: 0.1, key: nil) + _ = process(at: 0.2, key: .backslash) + _ = process(at: 0.3, key: nil) + let (stopOutput, stopWasIntercepted) = process(at: 1, key: .backslash) + + #expect(stopOutput == .stopRecording) + #expect(stopWasIntercepted) + } + + @Test + func unrelatedPrintableKeyThatStopsRecordingIsNotIntercepted() { + let hotkey = HotKey(key: .backslash, modifiers: []) + let keyEvent = KeyEvent(key: .a, modifiers: []) + + #expect(!HotKeyEventInterception.shouldIntercept( + output: .stopRecording, + keyEvent: keyEvent, + hotkey: hotkey, + useDoubleTapOnly: false + )) + } + // Tests releasing the hotkey stops recording @Test func pressAndHold_stopsRecordingOnHotkeyRelease_standard() throws {