VoiceInput/Sources/Core/KeyMonitor.swift
JOJO 1d9c9d5fa0 feat: macOS 菜单栏语音输入应用初始版本
- CGEvent cghidEventTap 全局监听 Fn 键
- Apple Speech 流式语音识别(zh-CN 默认)
- 实时音频 RMS 电平驱动波形动画
- NSPanel 胶囊浮动窗口
- 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换
- OpenAI 兼容 LLM 纠错(DeepSeek 预设)
- LSUIElement 菜单栏运行
- SPM + Makefile 构建
2026-04-24 16:17:57 +08:00

131 lines
4.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Cocoa
import CoreGraphics
/// Fn / Globe
final class KeyMonitor {
// Fn CGEventFlags.maskSecondaryFn = 0x00800000
private static let fnMask: UInt64 = 0x00800000
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var isFnPressed = false
var onFnDown: (() -> Void)?
var onFnUp: (() -> Void)?
func start() -> Bool {
guard eventTap == nil else { return true }
guard CGPreflightListenEventAccess() else {
CGRequestListenEventAccess()
print("[KeyMonitor] ❌ Accessibility permission not granted")
return false
}
let eventMask = (1 << CGEventType.flagsChanged.rawValue)
| (1 << CGEventType.keyDown.rawValue)
| (1 << CGEventType.keyUp.rawValue)
let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
let monitor = Unmanaged<KeyMonitor>.fromOpaque(refcon!).takeUnretainedValue()
return monitor.handleEvent(proxy: proxy, type: type, event: event)
}
// HID tap Fn
var tap = CGEvent.tapCreate(
tap: .cghidEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(eventMask),
callback: callback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
)
if tap == nil {
// 退 tap
print("[KeyMonitor] ⚠️ HID tap failed, falling back to session tap")
tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(eventMask),
callback: callback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
)
}
guard let tap = tap else {
print("[KeyMonitor] ❌ Failed to create event tap")
return false
}
eventTap = tap
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
print("[KeyMonitor] ✅ Started")
return true
}
func stop() {
guard let tap = eventTap else { return }
CGEvent.tapEnable(tap: tap, enable: false)
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
}
eventTap = nil
runLoopSource = nil
print("[KeyMonitor] Stopped")
}
private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
let flags = event.flags.rawValue
let fnInFlags = (flags & Self.fnMask) != 0
switch type {
case .flagsChanged:
if fnInFlags && !isFnPressed {
print("[KeyMonitor] ⚡️ Fn DOWN (flags=0x\(String(flags, radix: 16)))")
return handleFnDown()
}
if !fnInFlags && isFnPressed {
print("[KeyMonitor] ⚡️ Fn UP (flags=0x\(String(flags, radix: 16)))")
return handleFnUp()
}
return Unmanaged.passUnretained(event)
case .keyDown, .keyUp:
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
if keyCode == 63 && fnInFlags {
return handleFnDown()
}
return Unmanaged.passUnretained(event)
default:
return Unmanaged.passUnretained(event)
}
}
private func handleFnDown() -> Unmanaged<CGEvent>? {
if !isFnPressed {
isFnPressed = true
DispatchQueue.main.async { [weak self] in
self?.onFnDown?()
}
}
return nil
}
private func handleFnUp() -> Unmanaged<CGEvent>? {
if isFnPressed {
isFnPressed = false
DispatchQueue.main.async { [weak self] in
self?.onFnUp?()
}
}
return nil
}
}