swiftmacosaccessibility

Given an AXUIElement, how can I know in which NSScreen it is displayed?


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:

enter image description here

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) })

Solution

  • 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:

    1. Observe kAXMovedNotification on the desired window.
    2. Wait for the next mouse button release.
    3. Get the main screen.

    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