-
-
Save baquaz/387048aea7b70a07766fd69e3f0184e6 to your computer and use it in GitHub Desktop.
A service class for monitoring global keyboard events in macOS Swift applications.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// CGEventSupervisor.swift | |
// Mouseless Messenger Pro | |
// | |
// Created by Stephan Casas on 4/24/23. | |
// | |
import Cocoa; | |
class CGEventSupervisor { | |
static let shared = CGEventSupervisor(); | |
// MARK: - Constants | |
private let kDefaultEvents: [CGEventType] = [.keyDown, .leftMouseDown]; | |
// MARK: - Public / Control | |
/// Begin listening for events of the given type. | |
/// | |
func listen(for events: CGEventType...) { self.setup(for: events) } | |
/// Begin listening for the last-assigned type of events or, when no | |
/// events have been assigned, the default set of events. | |
/// | |
func listen() { self.setup() } | |
/// Stop listening for events. | |
func hangup() { self.teardown() } | |
// MARK: - Public / Callbacks | |
var nsEventCallbacks: NSEventCallbackStore { self.__nsEventCallbacks } | |
var cgEventCallbacks: CGEventCallbackStore { self.__cgEventCallbacks } | |
/// Install a named callback to evaluate to captured events as | |
/// instances of `NSEvent`. | |
/// | |
func install(_ callback: CallbackDescriptor<NSEventCallback>, as name: String) { | |
self.__nsEventCallbacks.updateValue(callback.rawValue, forKey: name); | |
} | |
/// Install a named callback to evaluate to captured events as | |
/// instances of `CGEvent`. | |
/// | |
func install(_ callback: CallbackDescriptor<CGEventCallback>, as name: String) { | |
self.__cgEventCallbacks.updateValue(callback.rawValue, forKey: name); | |
} | |
/// Uninstall a named callback which may be evaluating captured | |
/// events. | |
/// | |
func uninstall(callback: String) { | |
self.__nsEventCallbacks.removeValue(forKey: callback); | |
self.__cgEventCallbacks.removeValue(forKey: callback); | |
} | |
// MARK: - Private | |
private var listeningEvents: [CGEventType] = []; | |
private var __nsEventCallbacks: NSEventCallbackStore = [:]; | |
private var __cgEventCallbacks: CGEventCallbackStore = [:]; | |
private var eventTapMachPort: CFMachPort? = nil; | |
private func setup(for events: [CGEventType] = []) { | |
var events = events; | |
events = events.isEmpty ? ( | |
self.listeningEvents.isEmpty ? kDefaultEvents : self.listeningEvents | |
) : events; | |
/// Use existing mach port if no events changed. | |
/// | |
if let eventTapMachPort = self.eventTapMachPort, | |
self.listeningEvents == events | |
{ | |
CGEvent.tapEnable(tap: eventTapMachPort, enable: true); | |
return; | |
} | |
self.listeningEvents = events; | |
let eventMask = CGEventMask(self.listeningEvents.reduce(0, { | |
$0 | (1 << $1.rawValue) | |
})); | |
guard let eventTapMachPort = CGEvent.tapCreate( | |
tap: .cgSessionEventTap, | |
place: .headInsertEventTap, | |
options: .defaultTap, | |
eventsOfInterest: eventMask, | |
callback: CGEventSupervisorCallback, | |
userInfo: Unmanaged.passUnretained(self).toOpaque() | |
) else { | |
NSLog("CGEventSupervisor could not allocate the required mach port for CGEventTap.") | |
return; | |
} | |
/// Responding to Carbon/Quartz events -- use the main run loop. | |
/// | |
let runLoop = CFRunLoopGetCurrent(); | |
let runLoopSrc = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTapMachPort, 0); | |
CFRunLoopAddSource(runLoop, runLoopSrc, .commonModes); | |
CGEvent.tapEnable(tap: eventTapMachPort, enable: true); | |
self.eventTapMachPort = eventTapMachPort; | |
} | |
private func teardown() { | |
guard let eventTapMachPort = self.eventTapMachPort else { return } | |
CGEvent.tapEnable(tap: eventTapMachPort, enable: false); | |
} | |
// MARK: - Types | |
/// # Captured Event Callbacks | |
/// | |
/// The callbacks which will evaluate captured events. | |
/// | |
/// Callbacks may be configured to receive instances of | |
/// either `NSEvent` or `CGEvent` — depending on the use | |
/// case. | |
/// | |
/// From within a callback, `stop()` can be called to end | |
/// event propagation — preventing any subsequent callbacks | |
/// and the current first responder from receiving the event. | |
/// | |
typealias NSEventCallback = (_ event: NSEvent) -> Void; | |
typealias CGEventCallback = (_ event: CGEvent) -> Void; | |
/// # Captured Event Callbacks Dictionary | |
/// | |
/// The dictionary store for captured event | |
/// callbacks. | |
/// | |
typealias NSEventCallbackStore = [String: NSEventCallback]; | |
typealias CGEventCallbackStore = [String: CGEventCallback]; | |
struct CallbackDescriptor<T>: RawRepresentable { | |
var rawValue: T; | |
} | |
} | |
// MARK: - Event Supervisory Extensions | |
fileprivate var kSupervisedCGEventShouldCancelKey: String = "__SupervisedCGEventShouldCancel"; | |
fileprivate var kSupervisedNSEventShouldCancelKey: String = "__SupervisedNSEventShouldCancel"; | |
extension CGEvent { | |
/// If the event is supervised by `CGEventSupervisor`, setting this | |
/// to `true` will stop the event from propagating to the next-installed | |
/// callback and will prevent the event from reaching its intended target. | |
/// | |
fileprivate var supervisorShouldCancel: Bool { | |
set { objc_setAssociatedObject( | |
self, | |
&kSupervisedCGEventShouldCancelKey, | |
newValue, | |
.OBJC_ASSOCIATION_RETAIN_NONATOMIC | |
) } | |
get { objc_getAssociatedObject( | |
self, | |
&kSupervisedCGEventShouldCancelKey | |
) as? Bool ?? false } | |
} | |
/// If the event is supervised by `CGEventSupervisor`, end | |
/// propagation into the next-installed callback and do not | |
/// send the event to its user-intended target. | |
/// | |
func cancel() { | |
self.supervisorShouldCancel = true; | |
} | |
} | |
extension NSEvent { | |
/// If the event is supervised by `CGEventSupervisor`, setting this | |
/// to `true` will stop the event from propagating to the next-installed | |
/// callback and will prevent the event from reaching its intended target. | |
/// | |
fileprivate var supervisorShouldCancel: Bool { | |
set { objc_setAssociatedObject( | |
self, | |
&kSupervisedNSEventShouldCancelKey, | |
newValue, | |
.OBJC_ASSOCIATION_RETAIN_NONATOMIC | |
) } | |
get { objc_getAssociatedObject( | |
self, | |
&kSupervisedNSEventShouldCancelKey | |
) as? Bool ?? false } | |
} | |
/// If the event is supervised by `CGEventSupervisor`, end | |
/// propagation into the next-installed callback and do not | |
/// send the event to its user-intended target. | |
/// | |
func cancel() { | |
self.supervisorShouldCancel = true; | |
} | |
} | |
// MARK: - Callback Type Descriptors | |
extension CGEventSupervisor.CallbackDescriptor where T == CGEventSupervisor.NSEventCallback { | |
static func nsEventCallback(_ callback: T) -> Self { .init(rawValue: callback) } | |
} | |
extension CGEventSupervisor.CallbackDescriptor where T == CGEventSupervisor.CGEventCallback { | |
static func cgEventCallback(_ callback: T) -> Self { .init(rawValue: callback) } | |
} | |
// MARK: - Main Event Callback | |
/// The main event callback which will perform casting | |
/// for `CGEvent` to `NSEvent` and resolution of the | |
/// given opaque pointer into `CGEventSupervisor`. | |
/// | |
fileprivate func CGEventSupervisorCallback( | |
_ eventTap: CGEventTapProxy, | |
_ eventType: CGEventType, | |
_ event: CGEvent, | |
_ userData: UnsafeMutableRawPointer? | |
) -> Unmanaged<CGEvent>? { | |
guard | |
let supervisorRef = userData, | |
let nsEvent = NSEvent(cgEvent: event) | |
else { | |
return Unmanaged.passUnretained(event); | |
} | |
let supervisor = Unmanaged<CGEventSupervisor> | |
.fromOpaque(supervisorRef) | |
.takeUnretainedValue(); | |
var bubbles = true; | |
for callback in supervisor.cgEventCallbacks.values { | |
callback(event); | |
bubbles = !event.supervisorShouldCancel; | |
if !bubbles { return nil } | |
} | |
for callback in supervisor.nsEventCallbacks.values { | |
callback(nsEvent); | |
bubbles = !nsEvent.supervisorShouldCancel; | |
if !bubbles { return nil } | |
} | |
return Unmanaged.passUnretained(event); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment