561a547b70
Menu-bar utility that fixes cross-display keyboard focus loss: a listen-only CGEvent tap locates the window under the cursor and reasserts app activation + AX focus on it, with debounce, tap auto-recovery, and an Accessibility permission gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
4.2 KiB
Swift
122 lines
4.2 KiB
Swift
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<CGEvent>? {
|
|
if let refcon {
|
|
let controller = Unmanaged<EventTapController>.fromOpaque(refcon).takeUnretainedValue()
|
|
controller.handle(type: type, event: event)
|
|
}
|
|
return Unmanaged.passUnretained(event)
|
|
}
|