import AppKit
|
import ApplicationServices
|
@preconcurrency import CoreGraphics
|
import Foundation
|
import AlignerCore
|
|
@MainActor
|
final class CGEventTapTriggerService: TriggerServiceProtocol {
|
enum ServiceError: Error, CustomStringConvertible {
|
case eventTapUnavailable
|
|
var description: String {
|
switch self {
|
case .eventTapUnavailable:
|
return "CGEventTapCreate returned nil. Accessibility may be missing, or macOS may require a different trigger route."
|
}
|
}
|
}
|
|
var onQuickSwitch: (() -> Void)?
|
var onStartFailure: ((ServiceError) -> Void)?
|
private(set) var isRunning = false
|
|
private let shortcut: KeyboardShortcut
|
private let overlayVisibleProvider: () -> Bool
|
private let accessibilityTrustedProvider: () -> Bool
|
private let consumeInitialQuickSwitch: Bool
|
private var eventTap: CFMachPort?
|
private var runLoopSource: CFRunLoopSource?
|
|
init(
|
shortcut: KeyboardShortcut = TriggerDefaults.quickSwitchShortcut,
|
overlayVisibleProvider: @escaping () -> Bool,
|
accessibilityTrustedProvider: @escaping () -> Bool = {
|
AXIsProcessTrusted()
|
},
|
consumeInitialQuickSwitch: Bool = true
|
) {
|
self.shortcut = shortcut
|
self.overlayVisibleProvider = overlayVisibleProvider
|
self.accessibilityTrustedProvider = accessibilityTrustedProvider
|
self.consumeInitialQuickSwitch = consumeInitialQuickSwitch
|
}
|
|
func start() {
|
do {
|
try startEventTap()
|
} catch let error as ServiceError {
|
onStartFailure?(error)
|
} catch {
|
onStartFailure?(.eventTapUnavailable)
|
}
|
}
|
|
func startEventTap() throws {
|
guard eventTap == nil else { return }
|
|
let eventMask = CGEventMask(1 << CGEventType.keyDown.rawValue)
|
guard let tap = CGEvent.tapCreate(
|
tap: .cgSessionEventTap,
|
place: .headInsertEventTap,
|
options: .defaultTap,
|
eventsOfInterest: eventMask,
|
callback: Self.eventCallback,
|
userInfo: Unmanaged.passUnretained(self).toOpaque()
|
) else {
|
throw ServiceError.eventTapUnavailable
|
}
|
|
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
eventTap = tap
|
runLoopSource = source
|
isRunning = true
|
}
|
|
func stop() {
|
if let eventTap {
|
CGEvent.tapEnable(tap: eventTap, enable: false)
|
}
|
if let runLoopSource {
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
|
}
|
|
runLoopSource = nil
|
eventTap = nil
|
isRunning = false
|
}
|
|
deinit {
|
MainActor.assumeIsolated {
|
stop()
|
}
|
}
|
|
private func handle(event: CGEvent, type: CGEventType) -> Unmanaged<CGEvent>? {
|
switch type {
|
case .tapDisabledByTimeout, .tapDisabledByUserInput:
|
guard accessibilityTrustedProvider() else {
|
Task { @MainActor [weak self] in
|
self?.stop()
|
}
|
return Unmanaged.passUnretained(event)
|
}
|
if let eventTap {
|
CGEvent.tapEnable(tap: eventTap, enable: true)
|
}
|
return Unmanaged.passUnretained(event)
|
case .keyDown:
|
break
|
default:
|
return Unmanaged.passUnretained(event)
|
}
|
|
guard accessibilityTrustedProvider() else {
|
Task { @MainActor [weak self] in
|
self?.stop()
|
}
|
return Unmanaged.passUnretained(event)
|
}
|
|
let receivedShortcut = KeyboardShortcut(
|
keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)),
|
modifiers: Self.shortcutModifiers(from: event.flags)
|
)
|
let decision = TriggerEventPolicy.decision(
|
for: receivedShortcut,
|
configuredQuickSwitch: shortcut,
|
overlayVisible: overlayVisibleProvider(),
|
consumeInitialQuickSwitch: consumeInitialQuickSwitch
|
)
|
|
switch decision {
|
case .quickSwitch(let consumeEvent):
|
Task { @MainActor [weak self] in
|
self?.onQuickSwitch?()
|
}
|
return consumeEvent ? nil : Unmanaged.passUnretained(event)
|
case .passThrough:
|
return Unmanaged.passUnretained(event)
|
}
|
}
|
|
private static let eventCallback: CGEventTapCallBack = { _, type, event, userInfo in
|
guard let userInfo else {
|
return Unmanaged.passUnretained(event)
|
}
|
|
let service = Unmanaged<CGEventTapTriggerService>.fromOpaque(userInfo).takeUnretainedValue()
|
return MainActor.assumeIsolated {
|
return service.handle(event: event, type: type)
|
}
|
}
|
|
private static func shortcutModifiers(from flags: CGEventFlags) -> ShortcutModifiers {
|
var modifiers = ShortcutModifiers()
|
|
if flags.contains(.maskAlternate) {
|
modifiers.insert(.option)
|
}
|
if flags.contains(.maskCommand) {
|
modifiers.insert(.command)
|
}
|
if flags.contains(.maskShift) {
|
modifiers.insert(.shift)
|
}
|
if flags.contains(.maskControl) {
|
modifiers.insert(.control)
|
}
|
|
return modifiers
|
}
|
}
|