diff --git a/AudioType/Core/HotKeyBinding.swift b/AudioType/Core/HotKeyBinding.swift new file mode 100644 index 0000000..2921500 --- /dev/null +++ b/AudioType/Core/HotKeyBinding.swift @@ -0,0 +1,113 @@ +import AppKit +import Foundation + +/// A user-configurable hold-to-record hotkey binding. +/// +/// Only modifier-style keys are supported (fn, Shift, Cmd, Option, Control, Caps Lock). +/// Left and right variants of Shift/Cmd/Option/Control are distinct so the user can +/// dedicate one side to AudioType without breaking normal shortcuts on the other. +struct HotKeyBinding: Codable, Equatable { + let keyCode: Int64 + let flagMask: UInt64 + let displayName: String + + // MARK: - Known bindings + + static let fn = HotKeyBinding( + keyCode: 63, + flagMask: CGEventFlags.maskSecondaryFn.rawValue, + displayName: "fn" + ) + static let leftShift = HotKeyBinding( + keyCode: 56, + flagMask: CGEventFlags.maskShift.rawValue, + displayName: "Left Shift" + ) + static let rightShift = HotKeyBinding( + keyCode: 60, + flagMask: CGEventFlags.maskShift.rawValue, + displayName: "Right Shift" + ) + static let leftCommand = HotKeyBinding( + keyCode: 55, + flagMask: CGEventFlags.maskCommand.rawValue, + displayName: "Left Cmd" + ) + static let rightCommand = HotKeyBinding( + keyCode: 54, + flagMask: CGEventFlags.maskCommand.rawValue, + displayName: "Right Cmd" + ) + static let leftOption = HotKeyBinding( + keyCode: 58, + flagMask: CGEventFlags.maskAlternate.rawValue, + displayName: "Left Option" + ) + static let rightOption = HotKeyBinding( + keyCode: 61, + flagMask: CGEventFlags.maskAlternate.rawValue, + displayName: "Right Option" + ) + static let leftControl = HotKeyBinding( + keyCode: 59, + flagMask: CGEventFlags.maskControl.rawValue, + displayName: "Left Control" + ) + static let rightControl = HotKeyBinding( + keyCode: 62, + flagMask: CGEventFlags.maskControl.rawValue, + displayName: "Right Control" + ) + static let capsLock = HotKeyBinding( + keyCode: 57, + flagMask: CGEventFlags.maskAlphaShift.rawValue, + displayName: "Caps Lock" + ) + + static let allKnown: [HotKeyBinding] = [ + .fn, + .leftShift, .rightShift, + .leftCommand, .rightCommand, + .leftOption, .rightOption, + .leftControl, .rightControl, + .capsLock + ] + + static let defaultBinding: HotKeyBinding = .fn + + // MARK: - Lookup + + /// Look up a known binding by keyCode. Returns nil if the key is not a recognized modifier. + static func recognize(keyCode: Int64) -> HotKeyBinding? { + allKnown.first { $0.keyCode == keyCode } + } +} + +/// Persistence + change-notification for the active hotkey binding. +enum HotKeyBindingStore { + private static let userDefaultsKey = "hotKeyBinding" + static let didChangeNotification = Notification.Name("hotKeyBindingChanged") + + static var current: HotKeyBinding { + get { + guard + let data = UserDefaults.standard.data(forKey: userDefaultsKey), + let decoded = try? JSONDecoder().decode(HotKeyBinding.self, from: data) + else { + return .defaultBinding + } + // If the stored binding refers to an unknown keyCode (downgrade / removed binding), + // fall back to the default. + if HotKeyBinding.recognize(keyCode: decoded.keyCode) == nil { + return .defaultBinding + } + return decoded + } + set { + if let data = try? JSONEncoder().encode(newValue) { + UserDefaults.standard.set(data, forKey: userDefaultsKey) + } + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } + } +} diff --git a/AudioType/Core/HotKeyManager.swift b/AudioType/Core/HotKeyManager.swift index 6c4c146..1a2564c 100644 --- a/AudioType/Core/HotKeyManager.swift +++ b/AudioType/Core/HotKeyManager.swift @@ -20,8 +20,13 @@ class HotKeyManager { // releases its reference. We balance the retain in stopListening. private var refconRetained: Unmanaged? - // Track fn key state - private var fnKeyWasPressed = false + // Track whether the bound key is currently held. + private var bindingKeyWasPressed = false + + // The active hotkey binding, refreshed from HotKeyBindingStore on start + // and whenever the user changes it via Settings. + private var activeBinding: HotKeyBinding = HotKeyBindingStore.current + private var bindingChangeObserver: NSObjectProtocol? private let logger = Logger(subsystem: "com.audiotype", category: "HotKeyManager") @@ -32,7 +37,23 @@ class HotKeyManager { func startListening() { stopListening() - // Use CGEventTap for fn key detection + activeBinding = HotKeyBindingStore.current + + // Observe binding changes so the user can rebind without restarting the app. + bindingChangeObserver = NotificationCenter.default.addObserver( + forName: HotKeyBindingStore.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + let newBinding = HotKeyBindingStore.current + self.logger.info("Hotkey binding changed to \(newBinding.displayName, privacy: .public)") + self.activeBinding = newBinding + // If we were tracking a press on the old binding, drop it. + self.bindingKeyWasPressed = false + } + + // Use CGEventTap for modifier-key detection let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue) // Retain self for the duration of the tap. Released in stopListening. @@ -69,7 +90,7 @@ class HotKeyManager { CGEvent.tapEnable(tap: tap, enable: true) } - logger.info("Hotkey listener started (Hold fn)") + logger.info("Hotkey listener started (Hold \(self.activeBinding.displayName, privacy: .public))") } func stopListening() { @@ -87,7 +108,12 @@ class HotKeyManager { eventTap = nil runLoopSource = nil isRecording = false - fnKeyWasPressed = false + bindingKeyWasPressed = false + + if let observer = bindingChangeObserver { + NotificationCenter.default.removeObserver(observer) + bindingChangeObserver = nil + } // Balance the retain taken in startListening. Done last so any // callback already in-flight against the now-disabled tap still sees @@ -111,36 +137,38 @@ class HotKeyManager { return Unmanaged.passUnretained(event) } - let flags = event.flags - - // Check for fn key via the secondary fn flag - let fnPressed = flags.contains(.maskSecondaryFn) - let hasCommand = flags.contains(.maskCommand) - let hasShift = flags.contains(.maskShift) - let hasOption = flags.contains(.maskAlternate) - let hasControl = flags.contains(.maskControl) - - // Hold fn mode - detect via flagsChanged - if type == .flagsChanged { - let onlyFn = fnPressed && !hasCommand && !hasShift && !hasOption && !hasControl - - if onlyFn && !fnKeyWasPressed && !isRecording { - fnKeyWasPressed = true - isRecording = true - logger.info("fn key pressed - starting recording") - DispatchQueue.main.async { - self.callback(.keyDown) - } - } else if !fnPressed && fnKeyWasPressed && isRecording { - fnKeyWasPressed = false - isRecording = false - logger.info("fn key released - stopping recording") - DispatchQueue.main.async { - self.callback(.keyUp) - } - } else if !fnPressed { - fnKeyWasPressed = false + guard type == .flagsChanged else { + return Unmanaged.passUnretained(event) + } + + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + let binding = activeBinding + + // Only react to flag changes on the bound key. Other modifiers being held + // (e.g. Left Shift while the user presses Right Cmd as the binding) are + // ignored intentionally. + guard keyCode == binding.keyCode else { + return Unmanaged.passUnretained(event) + } + + let bindingBitSet = (event.flags.rawValue & binding.flagMask) != 0 + + if bindingBitSet && !bindingKeyWasPressed && !isRecording { + bindingKeyWasPressed = true + isRecording = true + logger.info("\(binding.displayName, privacy: .public) pressed - starting recording") + DispatchQueue.main.async { + self.callback(.keyDown) + } + } else if !bindingBitSet && bindingKeyWasPressed && isRecording { + bindingKeyWasPressed = false + isRecording = false + logger.info("\(binding.displayName, privacy: .public) released - stopping recording") + DispatchQueue.main.async { + self.callback(.keyUp) } + } else if !bindingBitSet { + bindingKeyWasPressed = false } return Unmanaged.passUnretained(event) diff --git a/AudioType/UI/SettingsView.swift b/AudioType/UI/SettingsView.swift index 61a184a..df6e5ca 100644 --- a/AudioType/UI/SettingsView.swift +++ b/AudioType/UI/SettingsView.swift @@ -1,4 +1,5 @@ import AVFoundation +import AppKit import ServiceManagement import Speech import SwiftUI @@ -25,6 +26,12 @@ struct SettingsView: View { SMAppService.mainApp.status == .requiresApproval @State private var launchAtLoginError: String? + // Hotkey binding state + @State private var hotKeyBinding: HotKeyBinding = HotKeyBindingStore.current + @State private var isCapturingHotKey = false + @State private var hotKeyCaptureWarning: String? + @State private var hotKeyEventMonitor: Any? + var body: some View { Form { // MARK: - Engine Selection @@ -175,10 +182,7 @@ struct SettingsView: View { // MARK: - General Section { - LabeledContent("Hotkey") { - Text("Hold fn") - .foregroundColor(.secondary) - } + hotKeyRow Toggle("Launch at Login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { newValue in @@ -260,6 +264,94 @@ struct SettingsView: View { ) { _ in refreshLaunchAtLoginStatus() } + .onDisappear { + stopHotKeyCapture(save: false) + } + } + + // MARK: - Hotkey row + + @ViewBuilder + private var hotKeyRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Hotkey") + Spacer() + Text("Hold \(hotKeyBinding.displayName)") + .foregroundColor(.secondary) + Button(isCapturingHotKey ? "Cancel" : "Change…") { + if isCapturingHotKey { + stopHotKeyCapture(save: false) + } else { + startHotKeyCapture() + } + } + } + + if isCapturingHotKey { + Text("Press a modifier key… (Esc to cancel)") + .font(.caption) + .foregroundColor(.secondary) + } + + if let warning = hotKeyCaptureWarning { + Text(warning) + .font(.caption) + .foregroundColor(.red) + } + } + } + + // MARK: - Hotkey capture + + private func startHotKeyCapture() { + hotKeyCaptureWarning = nil + isCapturingHotKey = true + + // Remove any stale monitor before installing a new one. + if let monitor = hotKeyEventMonitor { + NSEvent.removeMonitor(monitor) + hotKeyEventMonitor = nil + } + + hotKeyEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.flagsChanged, .keyDown] + ) { event in + // Esc cancels capture. + if event.type == .keyDown && event.keyCode == 53 { + stopHotKeyCapture(save: false) + return nil + } + + if event.type == .flagsChanged { + let keyCode = Int64(event.keyCode) + if let binding = HotKeyBinding.recognize(keyCode: keyCode) { + hotKeyBinding = binding + HotKeyBindingStore.current = binding + stopHotKeyCapture(save: true) + return nil + } + // flagsChanged for an unrecognized key shouldn't normally happen, but + // guard anyway. + hotKeyCaptureWarning = "That key isn't supported. Try Shift, Cmd, Option, Control, fn, or Caps Lock." + return nil + } + + // Any other keyDown (letter, F-key, etc.) is rejected; stay in capture mode. + hotKeyCaptureWarning = "Please press a modifier key (Shift, Cmd, Option, Control, fn, or Caps Lock)." + return nil + } + } + + private func stopHotKeyCapture(save: Bool) { + if let monitor = hotKeyEventMonitor { + NSEvent.removeMonitor(monitor) + hotKeyEventMonitor = nil + } + isCapturingHotKey = false + if save { + hotKeyCaptureWarning = nil + } } // MARK: - Shared API key field