I need to manage the position of an external application's window. Using the Accessibility API I'm able to get a reference to its AXUIElement, and then to its frame. I can also set its position to a desired frame.
I would like to know in which screen the window is displayed on, to help position it correctly with respect to its screen's bounds. How can I do this?
I noticed that relying on comparing the window's frame to that of each screen doesn't work, since the the window can be displayed in a screen that has less overlap with it than another one.
For instance here the window (green) is mostly on the left screen but displayed on the right screen:
let window: AXUIElement
// Computed from reading kAXPositionAttribute, kAXSizeAttribute from the window, and inverting the Y axis
let windowFrame: CGRect
// Those don't work
let screenWhereWindowIsDisplayed = NSScreen.screens.first(where: { $0.frame.contains(windowFrame) })
let screenWhereWindowIsDisplayed = NSScreen.screens.first(where: { $0.frame.contains(windowFrame.origin) })
I also couldn't find an easy way to find the screen where a window is being displayed.
I tried:
let containingScreens = NSScreen.screens.filter({ $0.frame.intersects( windowFrame ) })
but it wouldn't even always get both screens, depending on how the overlap was made.
Maybe I'm overlooking something here; but I found this long winding solution:
It is built purely on the observation that if you move the window to a new screen (also partially), NScreen.main
will be updated to that screen as soon as the window is displayed there.
These are the steps:
kAXMovedNotification
on the desired window.This works in an Xcode Playground:
import Cocoa
import Accessibility
import PlaygroundSupport
class Observer: NSObject {
var observer: AXObserver? = nil
func observe(app: pid_t, element: AXUIElement, notification: String) {
// Compiler error with `AXObserverCreate`: "A C function pointer cannot be formed from a local function that captures context"
// https://stackoverflow.com/questions/33260808/how-to-use-instance-method-as-callback-for-function-which-takes-only-func-or-lit
// Void pointer to `self`:
let selfPointer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
if AXObserverCreate(app, callBack, &observer) == .success {
guard let observer else { return }
print("\nAXObserverCreate success!")
if AXObserverAddNotification(observer, element, notification as CFString, selfPointer) == .success {
print("AXObserverAddNotification success! Watching '\(notification)' on window '\(getAXValue(element, "AXTitle") as? String ?? "<noTitle>")'.")
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
}
}
func callBack(observer: AXObserver?, element: AXUIElement?, notification: CFString, refcon: UnsafeMutableRawPointer?) {
print("Observer fired! \(notification)")
// Extract pointer to `self` from void pointer:
let selfPointer = Unmanaged<Observer>.fromOpaque(refcon!).takeUnretainedValue()
// Call instance method:
selfPointer.monitorMouseUp()
}
}
var mouseEventMonitor: Any?
private func monitorMouseUp() {
if mouseEventMonitor != nil { return }
mouseEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseUp]) { [self] event in
print("Observed window is now displayed on Screen '\(NSScreen.main!.localizedName)'")
NSEvent.removeMonitor(mouseEventMonitor as Any)
mouseEventMonitor = nil
}
print("mouseEventMonitor started")
}
}
func getAXValue(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
var result: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &result) == .success else {
return nil
}
return result
}
// Usage
let windowObserver = Observer()
// The choice of window is exemplary, you may take any `AXWindow` element
guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.localizedName == "Finder" }) else { exit(1) }
let targetApplicationProcessID: pid_t = app.processIdentifier
let appElement = AXUIElementCreateApplication(targetApplicationProcessID)
let windows = getAXValue(appElement, "AXChildren") as! [AXUIElement]
let observedWindow = windows.first
// Start the observation
windowObserver.observe(app: targetApplicationProcessID, element: observedWindow!, notification: kAXMovedNotification)
PlaygroundPage.current.needsIndefiniteExecution = true