Skip to content

Commit df1705a

Browse files
authored
Merge pull request #21 from ZhaoChaoqun/feat/custom-trigger-key
feat: add custom trigger key with any-key recording
2 parents f54aa0f + 2913426 commit df1705a

3 files changed

Lines changed: 351 additions & 29 deletions

File tree

Sources/KeyMonitor.swift

Lines changed: 256 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,96 @@ import os
55

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

8-
/// 监听全局 Fn 键按下/松开
8+
/// 触发键配置,支持修饰键和普通键
9+
struct TriggerKeyConfig: Codable, Equatable {
10+
/// 事件类型:flagsChanged (修饰键) 或 keyDown/keyUp (普通键)
11+
let isModifierKey: Bool
12+
/// 按键的 keyCode
13+
let keyCode: Int64
14+
/// 修饰键的 flag 掩码(仅修饰键有效,如 .maskSecondaryFn = 0x800000)
15+
let flagMask: UInt64
16+
/// 用户可读的按键名称
17+
let displayName: String
18+
19+
/// 默认触发键:Fn
20+
static let defaultFn = TriggerKeyConfig(
21+
isModifierKey: true,
22+
keyCode: -1, // Fn 不使用 keyCode,而是检查 flagMask
23+
flagMask: CGEventFlags.maskSecondaryFn.rawValue,
24+
displayName: "Fn"
25+
)
26+
27+
/// 从 UserDefaults 读取,默认 Fn
28+
static var current: TriggerKeyConfig {
29+
guard let data = UserDefaults.standard.data(forKey: "triggerKeyConfig"),
30+
let config = try? JSONDecoder().decode(TriggerKeyConfig.self, from: data) else {
31+
return .defaultFn
32+
}
33+
return config
34+
}
35+
36+
/// 保存到 UserDefaults
37+
func save() {
38+
if let data = try? JSONEncoder().encode(self) {
39+
UserDefaults.standard.set(data, forKey: "triggerKeyConfig")
40+
}
41+
}
42+
43+
/// 检查事件是否匹配此触发键
44+
func matches(type: CGEventType, event: CGEvent) -> Bool? {
45+
if isModifierKey {
46+
guard type == .flagsChanged else { return nil }
47+
if keyCode >= 0 {
48+
// 区分左右修饰键(如 Right Command keyCode=54)
49+
let eventKeyCode = event.getIntegerValueField(.keyboardEventKeycode)
50+
guard eventKeyCode == keyCode else { return nil }
51+
}
52+
return event.flags.rawValue & flagMask != 0
53+
} else {
54+
let eventKeyCode = event.getIntegerValueField(.keyboardEventKeycode)
55+
guard eventKeyCode == keyCode else { return nil }
56+
if type == .keyDown { return true }
57+
if type == .keyUp { return false }
58+
return nil
59+
}
60+
}
61+
}
62+
63+
// MARK: - Notification for trigger key changes
64+
65+
extension Notification.Name {
66+
static let triggerKeyChanged = Notification.Name("triggerKeyChanged")
67+
/// Settings UI 请求进入按键录制模式
68+
static let triggerKeyRecordingRequested = Notification.Name("triggerKeyRecordingRequested")
69+
/// Settings UI 取消按键录制
70+
static let triggerKeyRecordingCancelled = Notification.Name("triggerKeyRecordingCancelled")
71+
/// KeyMonitor 录制到新按键后通知 Settings UI(object 为 TriggerKeyConfig)
72+
static let triggerKeyRecorded = Notification.Name("triggerKeyRecorded")
73+
}
74+
75+
/// 监听全局触发键按下/松开(支持任意按键)
976
class KeyMonitor {
1077
var onKeyDown: (() -> Void)?
1178
var onKeyUp: (() -> Void)?
1279

80+
/// 按键录制模式的回调:录制到一个按键后回调
81+
var onKeyRecorded: ((TriggerKeyConfig) -> Void)?
82+
1383
private var eventTap: CFMachPort?
1484
private var runLoopSource: CFRunLoopSource?
15-
private var isFnPressed = false
85+
private var isTriggerPressed = false
1686
private var permissionCheckTimer: Timer?
1787

88+
/// 当前配置的触发键
89+
private var triggerConfig: TriggerKeyConfig = .current
90+
91+
/// 是否处于按键录制模式(通过锁保护,因为该值在 CGEvent tap 回调线程读取、主线程写入)
92+
private let _isRecordingKey = OSAllocatedUnfairLock(initialState: false)
93+
var isRecordingKey: Bool {
94+
get { _isRecordingKey.withLock { $0 } }
95+
set { _isRecordingKey.withLock { $0 = newValue } }
96+
}
97+
1898
func startMonitoring() {
1999
// 单元测试环境下跳过监听(避免弹出辅助功能权限窗口)
20100
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
@@ -42,9 +122,15 @@ class KeyMonitor {
42122
return
43123
}
44124

45-
// 监听 flagsChanged 事件(修饰键变化)
46-
let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue)
47-
logger.debug("事件掩码: \(eventMask, privacy: .public)")
125+
// 监听所有需要的事件类型(修饰键 + 普通键)
126+
let eventMask: CGEventMask =
127+
(1 << CGEventType.flagsChanged.rawValue) |
128+
(1 << CGEventType.keyDown.rawValue) |
129+
(1 << CGEventType.keyUp.rawValue)
130+
131+
// 刷新触发键配置
132+
triggerConfig = .current
133+
logger.info("触发键: \(self.triggerConfig.displayName, privacy: .public)")
48134

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

74-
logger.info("Fn 键监听已启动,按下 Fn 键测试")
75-
logger.debug("提示:如果你使用外接键盘,Fn 键检测可能不同")
160+
logger.info("触发键监听已启动(\(self.triggerConfig.displayName, privacy: .public)")
161+
}
162+
163+
/// 切换触发键后重新启动监听。
164+
/// ⚠️ 必须通过 DispatchQueue.main.async 异步调用,禁止在 CGEvent tap 回调中同步调用,
165+
/// 因为 stopMonitoring() 会销毁当前正在执行回调的 event tap,导致未定义行为。
166+
func restartWithNewTriggerKey() {
167+
if isTriggerPressed {
168+
onKeyUp?()
169+
}
170+
stopMonitoring()
171+
isTriggerPressed = false
172+
triggerConfig = .current
173+
startMonitoring()
76174
}
77175

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

186+
// 按键录制模式:捕获下一个按键作为新触发键
187+
if isRecordingKey {
188+
if let config = captureKeyConfig(type: type, event: event) {
189+
isRecordingKey = false
190+
logger.info("录制到新触发键: \(config.displayName, privacy: .public) (keyCode=\(config.keyCode))")
191+
DispatchQueue.main.async {
192+
self.onKeyRecorded?(config)
193+
}
194+
return nil // 吞掉录制的按键事件
195+
}
196+
return Unmanaged.passRetained(event)
197+
}
198+
199+
// 正常触发键检测模式
200+
if let pressed = triggerConfig.matches(type: type, event: event) {
201+
handleTriggerState(pressed: pressed)
202+
// 非修饰键需要吞掉事件,防止触发系统默认行为
203+
if !triggerConfig.isModifierKey {
204+
return nil
205+
}
206+
}
207+
208+
return Unmanaged.passRetained(event)
209+
}
210+
211+
/// 从事件中捕获按键配置(用于录制模式)
212+
private func captureKeyConfig(type: CGEventType, event: CGEvent) -> TriggerKeyConfig? {
213+
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
214+
88215
if type == .flagsChanged {
216+
// 修饰键:检测哪个 flag 变化了
89217
let flags = event.flags
90218

91-
// 检测 Fn 键状态
92-
// Fn 键通过 secondaryFn 标志检测
93-
let fnPressed = flags.contains(.maskSecondaryFn)
219+
if flags.contains(.maskSecondaryFn) {
220+
return TriggerKeyConfig(isModifierKey: true, keyCode: -1,
221+
flagMask: CGEventFlags.maskSecondaryFn.rawValue, displayName: "Fn")
222+
}
223+
if flags.contains(.maskCommand) {
224+
let name = keyCode == 54 ? "Right Command ⌘" : "Left Command ⌘"
225+
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
226+
flagMask: CGEventFlags.maskCommand.rawValue, displayName: name)
227+
}
228+
if flags.contains(.maskAlternate) {
229+
let name = keyCode == 61 ? "Right Option ⌥" : "Left Option ⌥"
230+
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
231+
flagMask: CGEventFlags.maskAlternate.rawValue, displayName: name)
232+
}
233+
if flags.contains(.maskControl) {
234+
let name = keyCode == 62 ? "Right Control ⌃" : "Left Control ⌃"
235+
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
236+
flagMask: CGEventFlags.maskControl.rawValue, displayName: name)
237+
}
238+
if flags.contains(.maskShift) {
239+
let name = keyCode == 60 ? "Right Shift ⇧" : "Left Shift ⇧"
240+
return TriggerKeyConfig(isModifierKey: true, keyCode: keyCode,
241+
flagMask: CGEventFlags.maskShift.rawValue, displayName: name)
242+
}
243+
return nil
244+
}
94245

95-
if fnPressed && !isFnPressed {
96-
// Fn 键按下
97-
isFnPressed = true
98-
logger.info("Fn 键按下,触发录音")
99-
DispatchQueue.main.async {
100-
self.onKeyDown?()
101-
}
102-
} else if !fnPressed && isFnPressed {
103-
// Fn 键松开
104-
isFnPressed = false
105-
logger.info("Fn 键松开,停止录音")
106-
DispatchQueue.main.async {
107-
self.onKeyUp?()
108-
}
246+
if type == .keyDown {
247+
// 普通键:用 keyCode 标识
248+
let name = keyCodeDisplayName(keyCode)
249+
return TriggerKeyConfig(isModifierKey: false, keyCode: keyCode,
250+
flagMask: 0, displayName: name)
251+
}
252+
253+
return nil
254+
}
255+
256+
/// 将 keyCode 转为人类可读名称
257+
private func keyCodeDisplayName(_ keyCode: Int64) -> String {
258+
// 常见特殊键
259+
switch keyCode {
260+
case 110: return "App / Menu"
261+
case 53: return "Escape"
262+
case 36: return "Return"
263+
case 48: return "Tab"
264+
case 49: return "Space"
265+
case 51: return "Delete"
266+
case 117: return "Forward Delete"
267+
case 114: return "Insert"
268+
case 115: return "Home"
269+
case 119: return "End"
270+
case 116: return "Page Up"
271+
case 121: return "Page Down"
272+
case 123: return ""
273+
case 124: return ""
274+
case 125: return ""
275+
case 126: return ""
276+
case 122: return "F1"
277+
case 120: return "F2"
278+
case 99: return "F3"
279+
case 118: return "F4"
280+
case 96: return "F5"
281+
case 97: return "F6"
282+
case 98: return "F7"
283+
case 100: return "F8"
284+
case 101: return "F9"
285+
case 109: return "F10"
286+
case 103: return "F11"
287+
case 111: return "F12"
288+
case 105: return "F13"
289+
case 107: return "F14"
290+
case 113: return "F15"
291+
default:
292+
// 尝试用 Carbon 获取字符名称
293+
if let char = keyCodeToCharacter(keyCode) {
294+
return String(char).uppercased()
109295
}
296+
return "Key \(keyCode)"
110297
}
298+
}
111299

112-
return Unmanaged.passRetained(event)
300+
/// 将 keyCode 转为字符(用于字母/数字键)
301+
private func keyCodeToCharacter(_ keyCode: Int64) -> Character? {
302+
guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { return nil }
303+
guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil }
304+
let layout = unsafeBitCast(layoutData, to: CFData.self)
305+
let keyboardLayout = unsafeBitCast(CFDataGetBytePtr(layout), to: UnsafePointer<UCKeyboardLayout>.self)
306+
307+
var deadKeyState: UInt32 = 0
308+
var chars = [UniChar](repeating: 0, count: 4)
309+
var length: Int = 0
310+
311+
let status = UCKeyTranslate(
312+
keyboardLayout,
313+
UInt16(keyCode),
314+
UInt16(kUCKeyActionDown),
315+
0, // no modifiers
316+
UInt32(LMGetKbdType()),
317+
UInt32(kUCKeyTranslateNoDeadKeysMask),
318+
&deadKeyState,
319+
chars.count,
320+
&length,
321+
&chars
322+
)
323+
324+
guard status == noErr, length > 0 else { return nil }
325+
guard let scalar = UnicodeScalar(chars[0]) else { return nil }
326+
return Character(scalar)
327+
}
328+
329+
/// 统一处理触发键状态变化
330+
private func handleTriggerState(pressed: Bool) {
331+
if pressed && !isTriggerPressed {
332+
isTriggerPressed = true
333+
logger.info("\(self.triggerConfig.displayName, privacy: .public) 按下,触发录音")
334+
DispatchQueue.main.async {
335+
self.onKeyDown?()
336+
}
337+
} else if !pressed && isTriggerPressed {
338+
isTriggerPressed = false
339+
logger.info("\(self.triggerConfig.displayName, privacy: .public) 松开,停止录音")
340+
DispatchQueue.main.async {
341+
self.onKeyUp?()
342+
}
343+
}
113344
}
114345

115346
func stopMonitoring() {

Sources/SettingsGeneralView.swift

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,56 @@ import SwiftUI
44
struct SettingsGeneralView: View {
55
@AppStorage("analyticsEnabled") private var analyticsEnabled = true
66

7+
/// 当前触发键的显示名称(从 TriggerKeyConfig 读取)
8+
@State private var triggerKeyName: String = TriggerKeyConfig.current.displayName
9+
/// 是否处于按键录制状态
10+
@State private var isRecording = false
11+
712
var body: some View {
8-
Section("快捷键") {
9-
Text("长按 Fn 键开始录音")
10-
.foregroundColor(.secondary)
13+
Section {
14+
HStack {
15+
Text("触发键")
16+
Spacer()
17+
if isRecording {
18+
Text("请按下任意键...")
19+
.foregroundStyle(.secondary)
20+
.italic()
21+
} else {
22+
Text(triggerKeyName)
23+
.foregroundStyle(.secondary)
24+
}
25+
Button(isRecording ? "取消" : "录制按键") {
26+
if isRecording {
27+
// 取消录制
28+
isRecording = false
29+
NotificationCenter.default.post(
30+
name: .triggerKeyRecordingCancelled, object: nil)
31+
} else {
32+
// 开始录制
33+
isRecording = true
34+
NotificationCenter.default.post(
35+
name: .triggerKeyRecordingRequested, object: nil)
36+
}
37+
}
38+
.buttonStyle(.bordered)
39+
}
40+
if triggerKeyName != TriggerKeyConfig.defaultFn.displayName {
41+
Button("恢复默认 (Fn)") {
42+
TriggerKeyConfig.defaultFn.save()
43+
triggerKeyName = TriggerKeyConfig.defaultFn.displayName
44+
NotificationCenter.default.post(name: .triggerKeyChanged, object: nil)
45+
}
46+
}
47+
} header: {
48+
Text("快捷键")
49+
} footer: {
50+
Text("长按所选按键开始录音,松开结束。外接机械键盘的 Fn 键通常无法被系统检测,建议录制其他按键。")
51+
}
52+
.onReceive(NotificationCenter.default.publisher(for: .triggerKeyRecorded)) { notification in
53+
if let config = notification.object as? TriggerKeyConfig {
54+
triggerKeyName = config.displayName
55+
isRecording = false
56+
}
1157
}
1258

1359
Section("关于") {

0 commit comments

Comments
 (0)