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,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user