Skip to content

Commit 635bcb0

Browse files
ZhaoChaoqunclaude
andcommitted
feat: add custom trigger key support with any-key recording
Allow users to configure any keyboard key (modifier or regular) as the push-to-talk trigger, replacing the fixed Fn-only approach. Adds a "record key" button in Settings that captures the next keypress as the new trigger key. Default remains Fn. - Rewrite KeyMonitor with TriggerKeyConfig struct (Codable, any key) - Add key recording mode with captureKeyConfig() and UCKeyTranslate - Replace fixed Picker with "press any key to record" UI in Settings - Dynamic menu bar text showing current trigger key name - Notification-based coordination between Settings UI and KeyMonitor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a450510 commit 635bcb0

3 files changed

Lines changed: 338 additions & 29 deletions

File tree

Sources/KeyMonitor.swift

Lines changed: 246 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,92 @@ 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+
/// 是否处于按键录制模式
92+
var isRecordingKey = false
93+
1894
func startMonitoring() {
1995
// 单元测试环境下跳过监听(避免弹出辅助功能权限窗口)
2096
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
@@ -42,9 +118,15 @@ class KeyMonitor {
42118
return
43119
}
44120

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

49131
guard let tap = CGEvent.tapCreate(
50132
tap: .cgSessionEventTap,
@@ -71,8 +153,15 @@ class KeyMonitor {
71153
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
72154
CGEvent.tapEnable(tap: tap, enable: true)
73155

74-
logger.info("Fn 键监听已启动,按下 Fn 键测试")
75-
logger.debug("提示:如果你使用外接键盘,Fn 键检测可能不同")
156+
logger.info("触发键监听已启动(\(self.triggerConfig.displayName, privacy: .public)")
157+
}
158+
159+
/// 切换触发键后重新启动监听
160+
func restartWithNewTriggerKey() {
161+
stopMonitoring()
162+
isTriggerPressed = false
163+
triggerConfig = .current
164+
startMonitoring()
76165
}
77166

78167
private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
@@ -85,31 +174,163 @@ class KeyMonitor {
85174
return Unmanaged.passRetained(event)
86175
}
87176

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

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

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-
}
237+
if type == .keyDown {
238+
// 普通键:用 keyCode 标识
239+
let name = keyCodeDisplayName(keyCode)
240+
return TriggerKeyConfig(isModifierKey: false, keyCode: keyCode,
241+
flagMask: 0, displayName: name)
242+
}
243+
244+
return nil
245+
}
246+
247+
/// 将 keyCode 转为人类可读名称
248+
private func keyCodeDisplayName(_ keyCode: Int64) -> String {
249+
// 常见特殊键
250+
switch keyCode {
251+
case 110: return "App / Menu"
252+
case 53: return "Escape"
253+
case 36: return "Return"
254+
case 48: return "Tab"
255+
case 49: return "Space"
256+
case 51: return "Delete"
257+
case 117: return "Forward Delete"
258+
case 114: return "Insert"
259+
case 115: return "Home"
260+
case 119: return "End"
261+
case 116: return "Page Up"
262+
case 121: return "Page Down"
263+
case 123: return ""
264+
case 124: return ""
265+
case 125: return ""
266+
case 126: return ""
267+
case 122: return "F1"
268+
case 120: return "F2"
269+
case 99: return "F3"
270+
case 118: return "F4"
271+
case 96: return "F5"
272+
case 97: return "F6"
273+
case 98: return "F7"
274+
case 100: return "F8"
275+
case 101: return "F9"
276+
case 109: return "F10"
277+
case 103: return "F11"
278+
case 111: return "F12"
279+
case 105: return "F13"
280+
case 107: return "F14"
281+
case 113: return "F15"
282+
default:
283+
// 尝试用 Carbon 获取字符名称
284+
if let char = keyCodeToCharacter(keyCode) {
285+
return String(char).uppercased()
109286
}
287+
return "Key \(keyCode)"
110288
}
289+
}
111290

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

115336
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)