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
281 changes: 256 additions & 25 deletions Sources/KeyMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,96 @@ import os

private let logger = Logger(subsystem: "com.typeless.app", category: "KeyMonitor")

/// 监听全局 Fn 键按下/松开
/// 触发键配置,支持修饰键和普通键
struct TriggerKeyConfig: Codable, Equatable {
/// 事件类型:flagsChanged (修饰键) 或 keyDown/keyUp (普通键)
let isModifierKey: Bool
/// 按键的 keyCode
let keyCode: Int64
/// 修饰键的 flag 掩码(仅修饰键有效,如 .maskSecondaryFn = 0x800000)
let flagMask: UInt64
/// 用户可读的按键名称
let displayName: String

/// 默认触发键:Fn
static let defaultFn = TriggerKeyConfig(
isModifierKey: true,
keyCode: -1, // Fn 不使用 keyCode,而是检查 flagMask
flagMask: CGEventFlags.maskSecondaryFn.rawValue,
displayName: "Fn"
)

/// 从 UserDefaults 读取,默认 Fn
static var current: TriggerKeyConfig {
guard let data = UserDefaults.standard.data(forKey: "triggerKeyConfig"),
let config = try? JSONDecoder().decode(TriggerKeyConfig.self, from: data) else {
return .defaultFn
}
return config
}

/// 保存到 UserDefaults
func save() {
if let data = try? JSONEncoder().encode(self) {
UserDefaults.standard.set(data, forKey: "triggerKeyConfig")
}
}

/// 检查事件是否匹配此触发键
func matches(type: CGEventType, event: CGEvent) -> Bool? {
if isModifierKey {
guard type == .flagsChanged else { return nil }
if keyCode >= 0 {
// 区分左右修饰键(如 Right Command keyCode=54)
let eventKeyCode = event.getIntegerValueField(.keyboardEventKeycode)
guard eventKeyCode == keyCode else { return nil }
}
return event.flags.rawValue & flagMask != 0
} else {
let eventKeyCode = event.getIntegerValueField(.keyboardEventKeycode)
guard eventKeyCode == keyCode else { return nil }
if type == .keyDown { return true }
if type == .keyUp { return false }
return nil
}
}
}

// MARK: - Notification for trigger key changes

extension Notification.Name {
static let triggerKeyChanged = Notification.Name("triggerKeyChanged")
/// Settings UI 请求进入按键录制模式
static let triggerKeyRecordingRequested = Notification.Name("triggerKeyRecordingRequested")
/// Settings UI 取消按键录制
static let triggerKeyRecordingCancelled = Notification.Name("triggerKeyRecordingCancelled")
/// KeyMonitor 录制到新按键后通知 Settings UI(object 为 TriggerKeyConfig)
static let triggerKeyRecorded = Notification.Name("triggerKeyRecorded")
}

/// 监听全局触发键按下/松开(支持任意按键)
class KeyMonitor {
var onKeyDown: (() -> Void)?
var onKeyUp: (() -> Void)?

/// 按键录制模式的回调:录制到一个按键后回调
var onKeyRecorded: ((TriggerKeyConfig) -> Void)?

private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var isFnPressed = false
private var isTriggerPressed = false
private var permissionCheckTimer: Timer?

/// 当前配置的触发键
private var triggerConfig: TriggerKeyConfig = .current

/// 是否处于按键录制模式(通过锁保护,因为该值在 CGEvent tap 回调线程读取、主线程写入)
private let _isRecordingKey = OSAllocatedUnfairLock(initialState: false)
var isRecordingKey: Bool {
get { _isRecordingKey.withLock { $0 } }
set { _isRecordingKey.withLock { $0 = newValue } }
}

func startMonitoring() {
// 单元测试环境下跳过监听(避免弹出辅助功能权限窗口)
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
Expand Down Expand Up @@ -42,9 +122,15 @@ class KeyMonitor {
return
}

// 监听 flagsChanged 事件(修饰键变化)
let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue)
logger.debug("事件掩码: \(eventMask, privacy: .public)")
// 监听所有需要的事件类型(修饰键 + 普通键)
let eventMask: CGEventMask =
(1 << CGEventType.flagsChanged.rawValue) |
(1 << CGEventType.keyDown.rawValue) |
(1 << CGEventType.keyUp.rawValue)

// 刷新触发键配置
triggerConfig = .current
logger.info("触发键: \(self.triggerConfig.displayName, privacy: .public)")

guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
Expand All @@ -71,8 +157,20 @@ class KeyMonitor {
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)

logger.info("Fn 键监听已启动,按下 Fn 键测试")
logger.debug("提示:如果你使用外接键盘,Fn 键检测可能不同")
logger.info("触发键监听已启动(\(self.triggerConfig.displayName, privacy: .public))")
}

/// 切换触发键后重新启动监听。
/// ⚠️ 必须通过 DispatchQueue.main.async 异步调用,禁止在 CGEvent tap 回调中同步调用,
/// 因为 stopMonitoring() 会销毁当前正在执行回调的 event tap,导致未定义行为。
func restartWithNewTriggerKey() {
if isTriggerPressed {
onKeyUp?()
}
stopMonitoring()
isTriggerPressed = false
Comment thread
claude[bot] marked this conversation as resolved.
triggerConfig = .current
startMonitoring()
}

private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
Expand All @@ -85,31 +183,164 @@ class KeyMonitor {
return Unmanaged.passRetained(event)
}

// 按键录制模式:捕获下一个按键作为新触发键
if isRecordingKey {
if let config = captureKeyConfig(type: type, event: event) {
isRecordingKey = false
logger.info("录制到新触发键: \(config.displayName, privacy: .public) (keyCode=\(config.keyCode))")
DispatchQueue.main.async {
self.onKeyRecorded?(config)
}
return nil // 吞掉录制的按键事件
}
return Unmanaged.passRetained(event)
}

// 正常触发键检测模式
if let pressed = triggerConfig.matches(type: type, event: event) {
handleTriggerState(pressed: pressed)
// 非修饰键需要吞掉事件,防止触发系统默认行为
if !triggerConfig.isModifierKey {
return nil
}
}

return Unmanaged.passRetained(event)
}

/// 从事件中捕获按键配置(用于录制模式)
private func captureKeyConfig(type: CGEventType, event: CGEvent) -> TriggerKeyConfig? {
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)

if type == .flagsChanged {
// 修饰键:检测哪个 flag 变化了
let flags = event.flags

// 检测 Fn 键状态
// Fn 键通过 secondaryFn 标志检测
let fnPressed = flags.contains(.maskSecondaryFn)
if flags.contains(.maskSecondaryFn) {
return TriggerKeyConfig(isModifierKey: true, keyCode: -1,
flagMask: CGEventFlags.maskSecondaryFn.rawValue, displayName: "Fn")
}
if flags.contains(.maskCommand) {
let name = keyCode == 54 ? "Right Command ⌘" : "Left Command ⌘"
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
flagMask: CGEventFlags.maskCommand.rawValue, displayName: name)
}
if flags.contains(.maskAlternate) {
let name = keyCode == 61 ? "Right Option ⌥" : "Left Option ⌥"
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
flagMask: CGEventFlags.maskAlternate.rawValue, displayName: name)
}
if flags.contains(.maskControl) {
let name = keyCode == 62 ? "Right Control ⌃" : "Left Control ⌃"
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
flagMask: CGEventFlags.maskControl.rawValue, displayName: name)
}
if flags.contains(.maskShift) {
let name = keyCode == 60 ? "Right Shift ⇧" : "Left Shift ⇧"
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
flagMask: CGEventFlags.maskShift.rawValue, displayName: name)
}
return nil
}

if fnPressed && !isFnPressed {
// Fn 键按下
isFnPressed = true
logger.info("Fn 键按下,触发录音")
DispatchQueue.main.async {
self.onKeyDown?()
}
} else if !fnPressed && isFnPressed {
// Fn 键松开
isFnPressed = false
logger.info("Fn 键松开,停止录音")
DispatchQueue.main.async {
self.onKeyUp?()
}
if type == .keyDown {
// 普通键:用 keyCode 标识
let name = keyCodeDisplayName(keyCode)
return TriggerKeyConfig(isModifierKey: false, keyCode: keyCode,
flagMask: 0, displayName: name)
}

return nil
}

/// 将 keyCode 转为人类可读名称
private func keyCodeDisplayName(_ keyCode: Int64) -> String {
// 常见特殊键
switch keyCode {
case 110: return "App / Menu"
case 53: return "Escape"
case 36: return "Return"
case 48: return "Tab"
case 49: return "Space"
case 51: return "Delete"
case 117: return "Forward Delete"
case 114: return "Insert"
case 115: return "Home"
case 119: return "End"
case 116: return "Page Up"
case 121: return "Page Down"
case 123: return "←"
case 124: return "→"
case 125: return "↓"
case 126: return "↑"
case 122: return "F1"
case 120: return "F2"
case 99: return "F3"
case 118: return "F4"
case 96: return "F5"
case 97: return "F6"
case 98: return "F7"
case 100: return "F8"
case 101: return "F9"
case 109: return "F10"
case 103: return "F11"
case 111: return "F12"
case 105: return "F13"
case 107: return "F14"
case 113: return "F15"
default:
// 尝试用 Carbon 获取字符名称
if let char = keyCodeToCharacter(keyCode) {
return String(char).uppercased()
}
return "Key \(keyCode)"
}
}

return Unmanaged.passRetained(event)
/// 将 keyCode 转为字符(用于字母/数字键)
private func keyCodeToCharacter(_ keyCode: Int64) -> Character? {
guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { return nil }
guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil }
let layout = unsafeBitCast(layoutData, to: CFData.self)
let keyboardLayout = unsafeBitCast(CFDataGetBytePtr(layout), to: UnsafePointer<UCKeyboardLayout>.self)

var deadKeyState: UInt32 = 0
var chars = [UniChar](repeating: 0, count: 4)
var length: Int = 0

let status = UCKeyTranslate(
keyboardLayout,
UInt16(keyCode),
UInt16(kUCKeyActionDown),
0, // no modifiers
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysMask),
&deadKeyState,
chars.count,
&length,
&chars
)

guard status == noErr, length > 0 else { return nil }
guard let scalar = UnicodeScalar(chars[0]) else { return nil }
return Character(scalar)
}

/// 统一处理触发键状态变化
private func handleTriggerState(pressed: Bool) {
if pressed && !isTriggerPressed {
isTriggerPressed = true
logger.info("\(self.triggerConfig.displayName, privacy: .public) 按下,触发录音")
DispatchQueue.main.async {
self.onKeyDown?()
}
} else if !pressed && isTriggerPressed {
isTriggerPressed = false
logger.info("\(self.triggerConfig.displayName, privacy: .public) 松开,停止录音")
DispatchQueue.main.async {
self.onKeyUp?()
}
}
}

func stopMonitoring() {
Expand Down
52 changes: 49 additions & 3 deletions Sources/SettingsGeneralView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,56 @@ import SwiftUI
struct SettingsGeneralView: View {
@AppStorage("analyticsEnabled") private var analyticsEnabled = true

/// 当前触发键的显示名称(从 TriggerKeyConfig 读取)
@State private var triggerKeyName: String = TriggerKeyConfig.current.displayName
/// 是否处于按键录制状态
@State private var isRecording = false

var body: some View {
Section("快捷键") {
Text("长按 Fn 键开始录音")
.foregroundColor(.secondary)
Section {
HStack {
Text("触发键")
Spacer()
if isRecording {
Text("请按下任意键...")
.foregroundStyle(.secondary)
.italic()
} else {
Text(triggerKeyName)
.foregroundStyle(.secondary)
}
Button(isRecording ? "取消" : "录制按键") {
if isRecording {
// 取消录制
isRecording = false
NotificationCenter.default.post(
name: .triggerKeyRecordingCancelled, object: nil)
} else {
// 开始录制
isRecording = true
NotificationCenter.default.post(
name: .triggerKeyRecordingRequested, object: nil)
}
}
.buttonStyle(.bordered)
}
if triggerKeyName != TriggerKeyConfig.defaultFn.displayName {
Button("恢复默认 (Fn)") {
TriggerKeyConfig.defaultFn.save()
triggerKeyName = TriggerKeyConfig.defaultFn.displayName
NotificationCenter.default.post(name: .triggerKeyChanged, object: nil)
}
}
} header: {
Text("快捷键")
} footer: {
Text("长按所选按键开始录音,松开结束。外接机械键盘的 Fn 键通常无法被系统检测,建议录制其他按键。")
}
.onReceive(NotificationCenter.default.publisher(for: .triggerKeyRecorded)) { notification in
if let config = notification.object as? TriggerKeyConfig {
triggerKeyName = config.displayName
isRecording = false
}
}

Section("关于") {
Expand Down
Loading
Loading