- updateText 移除文本宽度硬编码下限 160,真正根据内容自适应 - showRefining/showThinking 简化为调用 updateText,复用自适应逻辑 - 面板最小宽度由 minPanelWidth(180pt) 兜底 - 打包 VoiceInput-20260425.dmg
167 lines
5.9 KiB
Swift
167 lines
5.9 KiB
Swift
import Cocoa
|
||
import CoreGraphics
|
||
|
||
/// 全局热键监听器:监听 Fn 键(长按录音)和 Control 键(双击切换模式)
|
||
final class KeyMonitor {
|
||
|
||
// Fn 键标志
|
||
private static let fnMask: UInt64 = 0x00800000
|
||
// Control 键标志
|
||
private static let controlMask: UInt64 = 0x00040000
|
||
|
||
// 双击检测
|
||
private static let doubleClickWindow: TimeInterval = 0.40 // 两次点击的最大间隔
|
||
private static let longPressThreshold: TimeInterval = 0.20 // 长按判定阈值
|
||
|
||
private var eventTap: CFMachPort?
|
||
private var runLoopSource: CFRunLoopSource?
|
||
|
||
// Fn 键状态
|
||
private var isFnPressed = false
|
||
private var fnDownTime: TimeInterval = 0
|
||
private var longPressTimer: Timer?
|
||
|
||
// Control 键双击检测
|
||
private var isControlPressed = false
|
||
private var lastControlUpTime: TimeInterval = 0
|
||
|
||
// Fn 回调
|
||
var onFnDown: (() -> Void)?
|
||
var onFnUp: (() -> Void)?
|
||
|
||
// Control 回调
|
||
var onControlDoubleClick: (() -> Void)? // 双击 Control 切换模式
|
||
|
||
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)
|
||
|
||
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)
|
||
}
|
||
|
||
var tap = CGEvent.tapCreate(
|
||
tap: .cghidEventTap,
|
||
place: .headInsertEventTap,
|
||
options: .defaultTap,
|
||
eventsOfInterest: CGEventMask(eventMask),
|
||
callback: callback,
|
||
userInfo: Unmanaged.passUnretained(self).toOpaque()
|
||
)
|
||
|
||
if tap == nil {
|
||
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>? {
|
||
guard type == .flagsChanged else {
|
||
return Unmanaged.passUnretained(event)
|
||
}
|
||
|
||
let flags = event.flags.rawValue
|
||
let fnActive = (flags & Self.fnMask) != 0
|
||
let controlActive = (flags & Self.controlMask) != 0
|
||
|
||
// MARK: - Fn 键:长按录音
|
||
if fnActive && !isFnPressed {
|
||
isFnPressed = true
|
||
fnDownTime = ProcessInfo.processInfo.systemUptime
|
||
|
||
// 延迟 200ms 判定是否为长按
|
||
print("[KeyMonitor] ⚡️ Fn DOWN (waiting for long-press)")
|
||
longPressTimer?.invalidate()
|
||
longPressTimer = Timer.scheduledTimer(withTimeInterval: Self.longPressThreshold, repeats: false) { [weak self] _ in
|
||
guard let self = self, self.isFnPressed else { return }
|
||
print("[KeyMonitor] ⚡️ Fn LONG-PRESS → start recording")
|
||
self.longPressTimer = nil
|
||
DispatchQueue.main.async {
|
||
self.onFnDown?()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if !fnActive && isFnPressed {
|
||
isFnPressed = false
|
||
let fnUpTime = ProcessInfo.processInfo.systemUptime
|
||
|
||
// 取消长按 timer(说明是短按,不触发录音)
|
||
longPressTimer?.invalidate()
|
||
longPressTimer = nil
|
||
|
||
print("[KeyMonitor] ⚡️ Fn UP (was pressed for \(String(format: "%.0f", (fnUpTime - fnDownTime) * 1000))ms)")
|
||
|
||
// 如果长按 timer 已被触发(已开始录音),正常触发 onFnUp
|
||
if fnUpTime - fnDownTime >= Self.longPressThreshold {
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.onFnUp?()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Control 键:双击切换模式
|
||
if controlActive && !isControlPressed {
|
||
isControlPressed = true
|
||
let controlDownTime = ProcessInfo.processInfo.systemUptime
|
||
|
||
// 检测双击:上次松开距现在 < 400ms
|
||
let elapsed = controlDownTime - lastControlUpTime
|
||
if elapsed > 0 && elapsed < Self.doubleClickWindow {
|
||
print("[KeyMonitor] ⚡️ Control DOUBLE-CLICK → switch mode")
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.onControlDoubleClick?()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if !controlActive && isControlPressed {
|
||
isControlPressed = false
|
||
lastControlUpTime = ProcessInfo.processInfo.systemUptime
|
||
return nil
|
||
}
|
||
|
||
return Unmanaged.passUnretained(event)
|
||
}
|
||
}
|