import CoreGraphics import Foundation import os /// Suppresses events that arrive within `interval` of the previous accepted /// event. Clock is injectable so debounce timing can be unit tested without /// sleeping. struct Debouncer { private let interval: TimeInterval private let now: () -> TimeInterval private var lastAcceptedTime: TimeInterval? init(interval: TimeInterval, clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { self.interval = interval self.now = clock } mutating func shouldProcess() -> Bool { let current = now() if let last = lastAcceptedTime, current - last < interval { return false } lastAcceptedTime = current return true } } /// Owns the low-level CGEvent tap: creation, run-loop wiring, and recovery /// when macOS disables the tap for being too slow. The tap is listen-only /// and never mutates or swallows events. All work beyond capturing the /// event location happens off the callback thread. final class EventTapController { private let logger = Logger(subsystem: "com.local.focusfixer", category: "EventTapController") private let handoffQueue = DispatchQueue(label: "com.local.focusfixer.eventtap.handoff") private let preferences: Preferences private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? private var debouncer: Debouncer private var onMouseDown: ((CGPoint) -> Void)? init(preferences: Preferences = .shared) { self.preferences = preferences self.debouncer = Debouncer(interval: preferences.debounceInterval) } /// Starts listening for left mouse-down events. `onMouseDown` is /// invoked on `handoffQueue`, after debouncing, with the click location /// in global screen coordinates. func start(onMouseDown: @escaping (CGPoint) -> Void) { self.onMouseDown = onMouseDown let eventMask: CGEventMask = 1 << CGEventType.leftMouseDown.rawValue let selfPtr = Unmanaged.passUnretained(self).toOpaque() guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, options: .listenOnly, eventsOfInterest: eventMask, callback: eventTapCallback, userInfo: selfPtr ) else { logger.error("Failed to create event tap — Accessibility permission likely missing") return } eventTap = tap let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) runLoopSource = source CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) logger.info("Event tap started") } func stop() { if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: false) } if let source = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } eventTap = nil runLoopSource = nil logger.info("Event tap stopped") } fileprivate func handle(type: CGEventType, event: CGEvent) { if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { logger.error("Event tap disabled — re-enabling") if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: true) } return } guard type == .leftMouseDown else { return } let location = event.location handoffQueue.async { [weak self] in guard let self else { return } guard self.debouncer.shouldProcess() else { return } self.onMouseDown?(location) } } } /// Non-capturing C callback required by CGEvent.tapCreate. Recovers the /// controller instance from `refcon` rather than capturing `self`. private func eventTapCallback( proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer? ) -> Unmanaged? { if let refcon { let controller = Unmanaged.fromOpaque(refcon).takeUnretainedValue() controller.handle(type: type, event: event) } return Unmanaged.passUnretained(event) }