Files
tinystt/Sources/TinySTT/Hotkey/HotkeyMonitor.swift
T

165 lines
5.0 KiB
Swift

import AppKit
import CoreGraphics
/// 全局热键监听(只监听,不吞事件,不影响原有快捷键)。
/// 使用 CGEventTap,需要「辅助功能」或「输入监控」权限。
final class HotkeyMonitor {
enum Event {
case pressed
case released
case cancel
}
var handler: ((Event) -> Void)?
private var tap: CFMachPort?
private var source: CFRunLoopSource?
private var spec: HotkeySpec
private let secureInput: () -> Bool
private var modifierDown = false
private var comboDown = false
var suspended = false
init(spec: HotkeySpec, secureInput: @escaping () -> Bool = { Inserter.isSecureEventInputEnabled() }) {
self.spec = spec
self.secureInput = secureInput
}
deinit {
stop()
}
static var hasPermission: Bool {
AXIsProcessTrusted()
}
func update(spec: HotkeySpec) {
self.spec = spec
modifierDown = false
comboDown = false
}
@discardableResult
func start() -> Bool {
stop()
let mask: CGEventMask =
(1 << CGEventType.keyDown.rawValue) |
(1 << CGEventType.keyUp.rawValue) |
(1 << CGEventType.flagsChanged.rawValue)
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: mask,
callback: hotkeyCallback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
) else {
return false
}
self.tap = tap
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
self.source = source
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
return true
}
func stop() {
if let tap {
CGEvent.tapEnable(tap: tap, enable: false)
}
if let source {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
if let tap {
CFMachPortInvalidate(tap)
}
tap = nil
source = nil
}
func handle(type: CGEventType, event: CGEvent) {
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
Log.error("hotkey tap disabled type=\(type.rawValue)")
modifierDown = false
comboDown = false
fire(.cancel)
if let tap {
CGEvent.tapEnable(tap: tap, enable: true)
}
return
}
guard !suspended, event.getIntegerValueField(.eventSourceUserData) != Inserter.syntheticEventMarker else { return }
if secureInput() {
if modifierDown || comboDown { fire(.cancel) }
modifierDown = false
comboDown = false
return
}
let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode))
// Esc 取消
if type == .keyDown, keyCode == 53 {
fire(.cancel)
return
}
switch spec.kind {
case .modifier:
guard type == .flagsChanged, keyCode == spec.keyCode else { return }
let down = spec.isPressed(in: event.flags)
Log.info("modifier event code=\(keyCode) flags=\(event.flags.rawValue) down=\(down)")
if down, !modifierDown {
modifierDown = true
fire(.pressed)
} else if !down, modifierDown {
modifierDown = false
fire(.released)
}
case .combo:
let required = CGEventFlags(rawValue: spec.modifiers)
if type == .flagsChanged, comboDown, !event.flags.contains(required) {
comboDown = false
fire(.released)
return
}
guard keyCode == spec.keyCode else { return }
if type == .keyDown {
guard event.flags.contains(required) else { return }
let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0
guard !isRepeat, !comboDown else { return }
comboDown = true
fire(.pressed)
} else if type == .keyUp, comboDown {
comboDown = false
fire(.released)
}
}
}
private func fire(_ event: Event) {
// Never run AX, HUD creation or microphone start inside the event-tap
// callback: a slow handler causes macOS to disable the tap.
DispatchQueue.main.async { [weak self] in self?.handler?(event) }
}
}
private func hotkeyCallback(
proxy: CGEventTapProxy,
type: CGEventType,
event: CGEvent,
refcon: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
if let refcon {
let monitor = Unmanaged<HotkeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
monitor.handle(type: type, event: event)
}
return Unmanaged.passUnretained(event)
}