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,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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import AppKit
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
Reference in New Issue
Block a user