Files
openless/Sources/OpenLessHotkey/HotkeyMonitor.swift
T

176 lines
5.5 KiB
Swift

import AppKit
import CoreGraphics
import OpenLessCore
@MainActor
public final class HotkeyMonitor: HotkeyServiceProtocol {
public let events: AsyncStream<HotkeyEvent>
public private(set) var isRunning = false
private let continuation: AsyncStream<HotkeyEvent>.Continuation
private var binding: HotkeyBinding = .default
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var triggerHeld = false
/// Toggle 状态:每次触发键按下时发出 .toggled。
/// 主链路侧负责把第一次 toggled 解释为"开始",第二次为"结束"。
public init() {
var captured: AsyncStream<HotkeyEvent>.Continuation!
self.events = AsyncStream(bufferingPolicy: .unbounded) { continuation in
captured = continuation
}
self.continuation = captured
}
public func start(binding: HotkeyBinding) throws {
guard !isRunning else { throw HotkeyError.alreadyRunning }
guard AccessibilityPermission.isGranted() else {
throw HotkeyError.accessibilityNotGranted
}
self.binding = binding
let mask: CGEventMask =
(1 << CGEventType.flagsChanged.rawValue) |
(1 << CGEventType.keyDown.rawValue)
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: mask,
callback: hotkeyEventCallback,
userInfo: selfPtr
) else {
throw HotkeyError.eventTapCreateFailed
}
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
self.eventTap = tap
self.runLoopSource = source
self.isRunning = true
}
public func stop() {
guard isRunning else { return }
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
eventTap = nil
runLoopSource = nil
triggerHeld = false
isRunning = false
}
public func updateBinding(_ binding: HotkeyBinding) {
self.binding = binding
triggerHeld = false
}
fileprivate func reenableTap() {
guard let tap = eventTap else { return }
CGEvent.tapEnable(tap: tap, enable: true)
}
fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
switch type {
case .flagsChanged:
return handleFlagsChanged(event)
case .keyDown:
return handleKeyDown(event)
default:
return Unmanaged.passUnretained(event)
}
}
private func handleFlagsChanged(_ event: CGEvent) -> Unmanaged<CGEvent>? {
let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode))
let triggerKeyCode = expectedKeyCode(for: binding.trigger)
guard keyCode == triggerKeyCode else {
return Unmanaged.passUnretained(event)
}
let triggerMask = expectedFlagMask(for: binding.trigger)
let triggerActive = event.flags.contains(triggerMask)
if triggerActive && !triggerHeld {
triggerHeld = true
continuation.yield(.toggled)
} else if !triggerActive && triggerHeld {
triggerHeld = false
}
// fn 默认拦截,规避系统 Globe 行为
if shouldInterceptTrigger() {
return nil
}
return Unmanaged.passUnretained(event)
}
private func handleKeyDown(_ event: CGEvent) -> Unmanaged<CGEvent>? {
let escapeKeyCode: Int64 = 53
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
if keyCode == escapeKeyCode {
continuation.yield(.cancelled)
}
return Unmanaged.passUnretained(event)
}
private func shouldInterceptTrigger() -> Bool {
binding.trigger == .fn
}
private func expectedKeyCode(for trigger: HotkeyBinding.Trigger) -> Int {
switch trigger {
case .leftControl: return 59
case .rightCommand: return 54
case .leftOption: return 58
case .rightOption: return 61
case .rightControl: return 62
case .fn: return 63
}
}
private func expectedFlagMask(for trigger: HotkeyBinding.Trigger) -> CGEventFlags {
switch trigger {
case .leftControl, .rightControl: return .maskControl
case .rightCommand: return .maskCommand
case .leftOption, .rightOption: return .maskAlternate
case .fn: return .maskSecondaryFn
}
}
}
private func hotkeyEventCallback(
proxy: CGEventTapProxy,
type: CGEventType,
event: CGEvent,
refcon: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
guard let refcon = refcon else {
return Unmanaged.passUnretained(event)
}
let monitor = Unmanaged<HotkeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
MainActor.assumeIsolated {
monitor.reenableTap()
}
return Unmanaged.passUnretained(event)
}
return MainActor.assumeIsolated {
monitor.handle(type: type, event: event)
}
}