Skip to content
Merged
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
113 changes: 113 additions & 0 deletions AudioType/Core/HotKeyBinding.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
96 changes: 62 additions & 34 deletions AudioType/Core/HotKeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ class HotKeyManager {
// releases its reference. We balance the retain in stopListening.
private var refconRetained: Unmanaged<HotKeyManager>?

// 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")

Expand All @@ -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.
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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)
Expand Down
100 changes: 96 additions & 4 deletions AudioType/UI/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AVFoundation
import AppKit
import ServiceManagement
import Speech
import SwiftUI
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading