Files
focus-fixer/FocusFixer/EventTapController.swift
T
sttlab-tech 561a547b70 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>
2026-08-10 12:02:29 +02:00

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)
}