From 557ab8589a3dc79ed171fdc3711bba1a81611cc6 Mon Sep 17 00:00:00 2001 From: Oliver Sauter Date: Tue, 14 Jul 2026 23:18:36 +0200 Subject: [PATCH] Fix hotkey capture cancellation Use one shared capture target so only one settings shortcut field can listen at a time. Cancel capture when the user clicks elsewhere, Settings disappears, or Hex resigns active, and suspend all app hotkey monitors for the whole capture session. --- .changeset/unify-hotkey-capture-session.md | 5 ++ Hex/Features/App/AppFeature.swift | 4 +- Hex/Features/Settings/SettingsFeature.swift | 53 +++++++++---------- Hex/Features/Settings/SettingsView.swift | 27 ++++++++++ .../Transcription/TranscriptionFeature.swift | 4 +- Hex/Models/AppHexSettings.swift | 13 +++++ 6 files changed, 73 insertions(+), 33 deletions(-) create mode 100644 .changeset/unify-hotkey-capture-session.md diff --git a/.changeset/unify-hotkey-capture-session.md b/.changeset/unify-hotkey-capture-session.md new file mode 100644 index 000000000..0670cd1d2 --- /dev/null +++ b/.changeset/unify-hotkey-capture-session.md @@ -0,0 +1,5 @@ +--- +"hex-app": patch +--- + +Cancel hotkey capture when clicking away from a shortcut field. diff --git a/Hex/Features/App/AppFeature.swift b/Hex/Features/App/AppFeature.swift index cbeaeec1b..d280b07de 100644 --- a/Hex/Features/App/AppFeature.swift +++ b/Hex/Features/App/AppFeature.swift @@ -177,12 +177,12 @@ struct AppFeature { private func startPasteLastTranscriptMonitoring() -> Effect { .run { send in - @Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool + @Shared(.hotKeyCaptureTarget) var hotKeyCaptureTarget: HotKeyCaptureTarget? @Shared(.hexSettings) var hexSettings: HexSettings let token = keyEventMonitor.handleKeyEvent { keyEvent in // Skip if user is setting a hotkey - if isSettingPasteLastTranscriptHotkey { + if hotKeyCaptureTarget != nil { return false } diff --git a/Hex/Features/Settings/SettingsFeature.swift b/Hex/Features/Settings/SettingsFeature.swift index e61dac8d3..2fb57c3d1 100644 --- a/Hex/Features/Settings/SettingsFeature.swift +++ b/Hex/Features/Settings/SettingsFeature.swift @@ -12,22 +12,9 @@ import SwiftUI private let settingsLogger = HexLog.settings private typealias SettingsAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer) -> Void -private enum HotKeyCaptureTarget { - case recording - case pasteLastTranscript -} - extension SharedReaderKey where Self == InMemoryKey.Default { - static var isSettingHotKey: Self { - Self[.inMemory("isSettingHotKey"), default: false] - } - - static var isSettingPasteLastTranscriptHotkey: Self { - Self[.inMemory("isSettingPasteLastTranscriptHotkey"), default: false] - } - static var isRemappingScratchpadFocused: Self { Self[.inMemory("isRemappingScratchpadFocused"), default: false] } @@ -40,8 +27,7 @@ struct SettingsFeature { @ObservableState struct State { @Shared(.hexSettings) var hexSettings: HexSettings - @Shared(.isSettingHotKey) var isSettingHotKey: Bool = false - @Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool = false + @Shared(.hotKeyCaptureTarget) var hotKeyCaptureTarget: HotKeyCaptureTarget? @Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory @Shared(.hotkeyPermissionState) var hotkeyPermissionState: HotkeyPermissionState @@ -59,6 +45,9 @@ struct SettingsFeature { var modelDownload = ModelDownloadFeature.State() var shouldFlashModelSection = false + var isSettingHotKey: Bool { hotKeyCaptureTarget == .recording } + var isSettingPasteLastTranscriptHotkey: Bool { hotKeyCaptureTarget == .pasteLastTranscript } + } enum Action: BindableAction { @@ -68,6 +57,7 @@ struct SettingsFeature { case task case startSettingHotKey case startSettingPasteLastTranscriptHotkey + case cancelHotKeyCapture case clearPasteLastTranscriptHotkey case keyEvent(KeyEvent) case toggleOpenOnLogin(Bool) @@ -132,23 +122,25 @@ struct SettingsFeature { } private func beginCapture(_ target: HotKeyCaptureTarget, state: inout State) { + endCapture(state: &state) + state.$hotKeyCaptureTarget.withLock { $0 = target } + switch target { case .recording: - state.$isSettingHotKey.withLock { $0 = true } state.currentModifiers = .init(modifiers: []) case .pasteLastTranscript: - state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = true } state.currentPasteLastModifiers = .init(modifiers: []) } } - private func endCapture(_ target: HotKeyCaptureTarget, state: inout State) { + private func endCapture(state: inout State) { + guard let target = state.hotKeyCaptureTarget else { return } + state.$hotKeyCaptureTarget.withLock { $0 = nil } + switch target { case .recording: - state.$isSettingHotKey.withLock { $0 = false } state.currentModifiers = .init(modifiers: []) case .pasteLastTranscript: - state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = false } state.currentPasteLastModifiers = .init(modifiers: []) } } @@ -188,7 +180,7 @@ struct SettingsFeature { private func handleCapture(_ keyEvent: KeyEvent, for target: HotKeyCaptureTarget, state: inout State) -> Effect { if keyEvent.key == .escape { - endCapture(target, state: &state) + endCapture(state: &state) return .none } @@ -201,13 +193,13 @@ struct SettingsFeature { if let key = keyEvent.key { applyCapturedHotKey(key: key, modifiers: updatedModifiers, for: target, state: &state) - endCapture(target, state: &state) + endCapture(state: &state) return .none } if target == .recording, keyEvent.modifiers.isEmpty { applyCapturedHotKey(key: nil, modifiers: updatedModifiers, for: target, state: &state) - endCapture(target, state: &state) + endCapture(state: &state) } return .none @@ -414,18 +406,21 @@ struct SettingsFeature { case .startSettingPasteLastTranscriptHotkey: beginCapture(.pasteLastTranscript, state: &state) return .none + + case .cancelHotKeyCapture: + endCapture(state: &state) + return .none case .clearPasteLastTranscriptHotkey: + if state.hotKeyCaptureTarget == .pasteLastTranscript { + endCapture(state: &state) + } state.$hexSettings.withLock { $0.pasteLastTranscriptHotkey = nil } return .none case let .keyEvent(keyEvent): - if state.isSettingPasteLastTranscriptHotkey { - return handleCapture(keyEvent, for: .pasteLastTranscript, state: &state) - } - - guard state.isSettingHotKey else { return .none } - return handleCapture(keyEvent, for: .recording, state: &state) + guard let target = state.hotKeyCaptureTarget else { return .none } + return handleCapture(keyEvent, for: target, state: &state) case let .toggleOpenOnLogin(enabled): state.$hexSettings.withLock { $0.openOnLogin = enabled } diff --git a/Hex/Features/Settings/SettingsView.swift b/Hex/Features/Settings/SettingsView.swift index 7d15e84cf..330f3c967 100644 --- a/Hex/Features/Settings/SettingsView.swift +++ b/Hex/Features/Settings/SettingsView.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import HexCore import Inject @@ -6,6 +7,7 @@ import SwiftUI struct SettingsView: View { @ObserveInjection var inject @Bindable var store: StoreOf + @State private var localEventMonitor: Any? let microphonePermission: PermissionStatus let accessibilityPermission: PermissionStatus let inputMonitoringPermission: PermissionStatus @@ -43,8 +45,33 @@ struct SettingsView: View { .task { await store.send(.task).finish() } + .onAppear(perform: installHotKeyCaptureCancellationMonitor) + .onDisappear { + store.send(.cancelHotKeyCapture) + removeHotKeyCaptureCancellationMonitor() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in + store.send(.cancelHotKeyCapture) + } .enableInjection() } + + private func installHotKeyCaptureCancellationMonitor() { + guard localEventMonitor == nil else { return } + + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { event in + guard store.hotKeyCaptureTarget != nil else { return event } + store.send(.cancelHotKeyCapture) + return event + } + } + + private func removeHotKeyCaptureCancellationMonitor() { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + } } // MARK: - Shared Styles diff --git a/Hex/Features/Transcription/TranscriptionFeature.swift b/Hex/Features/Transcription/TranscriptionFeature.swift index 4e2d20064..7a7cfdec1 100644 --- a/Hex/Features/Transcription/TranscriptionFeature.swift +++ b/Hex/Features/Transcription/TranscriptionFeature.swift @@ -163,13 +163,13 @@ private extension TranscriptionFeature { func startHotKeyMonitoringEffect() -> Effect { .run { send in var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option])) - @Shared(.isSettingHotKey) var isSettingHotKey: Bool + @Shared(.hotKeyCaptureTarget) var hotKeyCaptureTarget: HotKeyCaptureTarget? @Shared(.hexSettings) var hexSettings: HexSettings // Handle incoming input events (keyboard and mouse) let token = keyEventMonitor.handleInputEvent { inputEvent in // Skip if the user is currently setting a hotkey - if isSettingHotKey { + if hotKeyCaptureTarget != nil { return false } diff --git a/Hex/Models/AppHexSettings.swift b/Hex/Models/AppHexSettings.swift index 07b7782ec..22e767614 100644 --- a/Hex/Models/AppHexSettings.swift +++ b/Hex/Models/AppHexSettings.swift @@ -7,6 +7,11 @@ import HexCore typealias RecordingAudioBehavior = HexCore.RecordingAudioBehavior typealias HexSettings = HexCore.HexSettings +enum HotKeyCaptureTarget: Equatable, Sendable { + case recording + case pasteLastTranscript +} + extension SharedReaderKey where Self == FileStorageKey.Default { @@ -18,6 +23,14 @@ extension SharedReaderKey } } +extension SharedReaderKey + where Self == InMemoryKey.Default +{ + static var hotKeyCaptureTarget: Self { + Self[.inMemory("hotKeyCaptureTarget"), default: nil] + } +} + // MARK: - Storage Migration extension URL {