165 lines
5.0 KiB
Swift
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)
|
|
}
|