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>
61 lines
2.5 KiB
Swift
61 lines
2.5 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|