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:
sttlab-tech
2026-08-10 12:02:29 +02:00
commit 561a547b70
18 changed files with 1411 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
import AppKit
import ServiceManagement
import os
/// App lifecycle, the Accessibility permission gate, and the menu-bar item.
/// Wires WindowLocator + FocusEnforcer to the events EventTapController
/// reports; owns no event-tap or AX logic itself.
final class AppDelegate: NSObject, NSApplicationDelegate {
private let logger = Logger(subsystem: "com.local.focusfixer", category: "AppDelegate")
private let preferences = Preferences.shared
private let permissionManager = PermissionManager()
private let eventTapController = EventTapController()
private let focusEnforcer = FocusEnforcer()
private var statusItem: NSStatusItem?
func applicationDidFinishLaunching(_ notification: Notification) {
setUpStatusItem()
permissionManager.waitUntilTrusted { [weak self] in
self?.startEventTap()
}
}
private func startEventTap() {
eventTapController.start { [weak self] point in
self?.handleMouseDown(at: point)
}
}
private func handleMouseDown(at point: CGPoint) {
guard preferences.isEnabled else { return }
guard let pid = WindowLocator.ownerPID(at: point) else { return }
focusEnforcer.enforceFocus(forWindowOwnedBy: pid)
}
// MARK: - Menu bar
private func setUpStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.button?.image = NSImage(systemSymbolName: "scope", accessibilityDescription: "FocusFixer")
let menu = NSMenu()
let enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled(_:)), keyEquivalent: "")
enabledItem.target = self
enabledItem.state = preferences.isEnabled ? .on : .off
menu.addItem(enabledItem)
let loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin(_:)), keyEquivalent: "")
loginItem.target = self
loginItem.state = SMAppService.mainApp.status == .enabled ? .on : .off
menu.addItem(loginItem)
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Quit FocusFixer", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
item.menu = menu
statusItem = item
}
@objc private func toggleEnabled(_ sender: NSMenuItem) {
preferences.isEnabled.toggle()
sender.state = preferences.isEnabled ? .on : .off
}
@objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
do {
if SMAppService.mainApp.status == .enabled {
try SMAppService.mainApp.unregister()
} else {
try SMAppService.mainApp.register()
}
} catch {
logger.error("Failed to toggle login item: \(error.localizedDescription, privacy: .public)")
}
sender.state = SMAppService.mainApp.status == .enabled ? .on : .off
}
}
+121
View File
@@ -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)
}
+60
View File
@@ -0,0 +1,60 @@
import AppKit
import ApplicationServices
import os
/// Activates the app that owns a window and reasserts keyboard focus on
/// that specific window via the Accessibility API.
final class FocusEnforcer {
private let logger = Logger(subsystem: "com.local.focusfixer", category: "FocusEnforcer")
private let preferences: Preferences
init(preferences: Preferences = .shared) {
self.preferences = preferences
}
/// Activates the owning app and raises/focuses its window, unless it is
/// this app, already frontmost, or user-excluded.
func enforceFocus(forWindowOwnedBy pid: pid_t) {
guard pid != ProcessInfo.processInfo.processIdentifier else {
return
}
guard let app = NSRunningApplication(processIdentifier: pid) else {
logger.debug("No running application for pid \(pid, privacy: .public)")
return
}
if let bundleID = app.bundleIdentifier, preferences.isExcluded(bundleID: bundleID) {
return
}
guard !app.isActive else {
return
}
if !app.activate(options: []) {
logger.debug("Activation returned false for pid \(pid, privacy: .public)")
}
raiseFocusedWindow(pid: pid)
}
private func raiseFocusedWindow(pid: pid_t) {
let axApp = AXUIElementCreateApplication(pid)
var windowRef: CFTypeRef?
let copyResult = AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &windowRef)
guard copyResult == .success, let windowRef else {
logger.debug("Could not resolve focused window via AX for pid \(pid, privacy: .public): \(copyResult.rawValue, privacy: .public)")
return
}
// AXUIElementCopyAttributeValue guarantees an AXUIElement on success; the
// Swift compiler can't express that constraint, hence the forced cast.
let axWindow = windowRef as! AXUIElement
let raiseResult = AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
if raiseResult != .success {
logger.debug("Raise action failed for pid \(pid, privacy: .public): \(raiseResult.rawValue, privacy: .public)")
}
let focusResult = AXUIElementSetAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, axWindow)
if focusResult != .success {
logger.debug("Setting focused window failed for pid \(pid, privacy: .public): \(focusResult.rawValue, privacy: .public)")
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026.</string>
</dict>
</plist>
+47
View File
@@ -0,0 +1,47 @@
import ApplicationServices
import Foundation
import os
/// Requests and polls for the Accessibility (TCC) grant FocusFixer needs
/// to observe events and drive the AX API.
final class PermissionManager {
private let logger = Logger(subsystem: "com.local.focusfixer", category: "PermissionManager")
private var pollTimer: Timer?
var isTrusted: Bool {
AXIsProcessTrusted()
}
/// Shows the system Accessibility prompt (if not already trusted) and
/// polls until the grant appears, then calls `onGranted` on the main
/// queue. Safe to call repeatedly; a prior poll is cancelled first.
func waitUntilTrusted(pollInterval: TimeInterval = 1.0, onGranted: @escaping () -> Void) {
stopPolling()
if isTrusted {
onGranted()
return
}
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
logger.info("Accessibility permission not granted yet; prompting and polling")
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
guard self.isTrusted else { return }
timer.invalidate()
self.pollTimer = nil
self.logger.info("Accessibility permission granted")
onGranted()
}
}
func stopPolling() {
pollTimer?.invalidate()
pollTimer = nil
}
}
+54
View File
@@ -0,0 +1,54 @@
import Foundation
/// UserDefaults-backed settings for FocusFixer.
final class Preferences {
static let shared = Preferences()
private enum Key {
static let enabled = "enabled"
static let excludedBundleIDs = "excludedBundleIDs"
static let debounceIntervalMS = "debounceIntervalMS"
}
private static let defaultDebounceIntervalMS = 150
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: [
Key.enabled: true,
Key.excludedBundleIDs: [String](),
Key.debounceIntervalMS: Self.defaultDebounceIntervalMS
])
}
var isEnabled: Bool {
get { defaults.bool(forKey: Key.enabled) }
set { defaults.set(newValue, forKey: Key.enabled) }
}
var excludedBundleIDs: Set<String> {
get { Set(defaults.stringArray(forKey: Key.excludedBundleIDs) ?? []) }
set { defaults.set(Array(newValue), forKey: Key.excludedBundleIDs) }
}
var debounceInterval: TimeInterval {
get { TimeInterval(defaults.integer(forKey: Key.debounceIntervalMS)) / 1000.0 }
set { defaults.set(Int(newValue * 1000), forKey: Key.debounceIntervalMS) }
}
func isExcluded(bundleID: String) -> Bool {
excludedBundleIDs.contains(bundleID)
}
func setExcluded(_ excluded: Bool, bundleID: String) {
var ids = excludedBundleIDs
if excluded {
ids.insert(bundleID)
} else {
ids.remove(bundleID)
}
excludedBundleIDs = ids
}
}
+49
View File
@@ -0,0 +1,49 @@
import CoreGraphics
import os
/// A single on-screen window as reported by the window server, reduced to
/// the fields FocusFixer needs. Kept separate from the raw CGWindowList
/// dictionary so the resolution logic below can be unit tested with
/// synthetic data instead of the real window server.
struct WindowInfo: Equatable {
let ownerPID: pid_t
let layer: Int
let bounds: CGRect
}
enum WindowLocator {
private static let logger = Logger(subsystem: "com.local.focusfixer", category: "WindowLocator")
/// Resolves the owning process ID of the frontmost on-screen window
/// under `point`, using the live window server state.
static func ownerPID(at point: CGPoint) -> pid_t? {
guard let list = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else {
logger.error("CGWindowListCopyWindowInfo returned nil")
return nil
}
return ownerPID(at: point, in: list.compactMap(parse))
}
/// Pure resolution logic: `windows` is assumed to be in front-to-back
/// z-order, matching CGWindowListCopyWindowInfo's contract. Only
/// layer-0 windows (ordinary app windows, excluding menu bar, Dock,
/// Spotlight, and Control Center) are considered.
static func ownerPID(at point: CGPoint, in windows: [WindowInfo]) -> pid_t? {
for window in windows where window.layer == 0 && window.bounds.contains(point) {
return window.ownerPID
}
return nil
}
private static func parse(_ dict: [String: Any]) -> WindowInfo? {
guard
let pid = dict[kCGWindowOwnerPID as String] as? pid_t,
let layer = dict[kCGWindowLayer as String] as? Int,
let boundsDict = dict[kCGWindowBounds as String] as? [String: Any],
let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary)
else {
return nil
}
return WindowInfo(ownerPID: pid, layer: layer, bounds: bounds)
}
}
+6
View File
@@ -0,0 +1,6 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()