Initial FocusFixer implementation
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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user