Skip to content

Commit

Permalink
feat: improve performance and lower resources consumption
Browse files Browse the repository at this point in the history
avoid unnecessary AX calls and batch calls when possible
  • Loading branch information
lwouis committed Feb 13, 2025
1 parent c72fedb commit 9d78700
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 147 deletions.
225 changes: 125 additions & 100 deletions src/api-wrappers/AXUIElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,29 @@ extension AXUIElement {
return try axCallWhichCanThrow(AXUIElementCopyAttributeValue(self, key as CFString, &value), &value) as? T
}

func windowAttributes() throws -> (String?, String?, String?, Bool, Bool)? {
let attributes = [
kAXTitleAttribute,
kAXRoleAttribute,
kAXSubroleAttribute,
kAXMinimizedAttribute,
kAXFullscreenAttribute,
]
var values: CFArray?
if let array = ((try axCallWhichCanThrow(AXUIElementCopyMultipleAttributeValues(self, attributes as CFArray, [], &values), &values)) as? Array<Any>) {
return (
array[0] as? String,
array[1] as? String,
array[2] as? String,
// if the value is nil, we return false. This avoid returning Bool?; simplifies things
(array[3] as? Bool) ?? false,
// if the value is nil, we return false. This avoid returning Bool?; simplifies things
(array[4] as? Bool) ?? false
)
}
return nil
}

private func value<T>(_ key: String, _ target: T, _ type: AXValueType) throws -> T? {
if let a = try attribute(key, AXValue.self) {
var value = target
Expand All @@ -110,6 +133,102 @@ extension AXUIElement {
return nil
}

func title() throws -> String? {
return try attribute(kAXTitleAttribute, String.self)
}

// periphery:ignore
func parent() throws -> AXUIElement? {
return try attribute(kAXParentAttribute, AXUIElement.self)
}

func children() throws -> [AXUIElement]? {
return try attribute(kAXChildrenAttribute, [AXUIElement].self)
}

func isMinimized() throws -> Bool {
// if the AX call doesn't return, we return false. This avoid returning Bool?; simplifies things
return try attribute(kAXMinimizedAttribute, Bool.self) == true
}

func isFullscreen() throws -> Bool {
// if the AX call doesn't return, we return false. This avoid returning Bool?; simplifies things
return try attribute(kAXFullscreenAttribute, Bool.self) == true
}

func focusedWindow() throws -> AXUIElement? {
return try attribute(kAXFocusedWindowAttribute, AXUIElement.self)
}

func role() throws -> String? {
return try attribute(kAXRoleAttribute, String.self)
}

func subrole() throws -> String? {
return try attribute(kAXSubroleAttribute, String.self)
}

func closeButton() throws -> AXUIElement? {
return try attribute(kAXCloseButtonAttribute, AXUIElement.self)
}

func appIsRunning() throws -> Bool? {
return try attribute(kAXIsApplicationRunningAttribute, Bool.self)
}

/// doesn't return windows on other Spaces
/// use windowsByBruteForce if you want those
func windows() throws -> [AXUIElement] {
let windows = try attribute(kAXWindowsAttribute, [AXUIElement].self)
if let windows,
!windows.isEmpty {
// bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login)
let uniqueWindows = Array(Set(windows))
if !uniqueWindows.isEmpty {
return uniqueWindows
}
}
return []
}

func position() throws -> CGPoint? {
return try value(kAXPositionAttribute, CGPoint.zero, .cgPoint)
}

func size() throws -> CGSize? {
return try value(kAXSizeAttribute, CGSize.zero, .cgSize)
}

/// we combine both the normal approach and brute-force to get all possible windows
/// with only normal approach: we miss other-Spaces windows
/// with only brute-force approach: we miss windows when the app launches (e.g. launch Note.app: first window is not found by brute-force)
func allWindows(_ pid: pid_t) throws -> [AXUIElement] {
let aWindows = try! windows()
let bWindows = AXUIElement.windowsByBruteForce(pid)
return Array(Set(aWindows + bWindows))
}

/// brute-force getting the windows of a process by iterating over AXUIElementID one by one
private static func windowsByBruteForce(_ pid: pid_t) -> [AXUIElement] {
// we use this to call _AXUIElementCreateWithRemoteToken; we reuse the object for performance
// tests showed that this remoteToken is 20 bytes: 4 + 4 + 4 + 8; the order of bytes matters
var remoteToken = Data(count: 20)
remoteToken.replaceSubrange(0..<4, with: withUnsafeBytes(of: pid) { Data($0) })
remoteToken.replaceSubrange(4..<8, with: withUnsafeBytes(of: Int32(0)) { Data($0) })
remoteToken.replaceSubrange(8..<12, with: withUnsafeBytes(of: Int32(0x636f636f)) { Data($0) })
var axWindows = [AXUIElement]()
// we iterate to 1000 as a tradeoff between performance, and missing windows of long-lived processes
for axUiElementId: AXUIElementID in 0..<1000 {
remoteToken.replaceSubrange(12..<20, with: withUnsafeBytes(of: axUiElementId) { Data($0) })
if let axUiElement = _AXUIElementCreateWithRemoteToken(remoteToken as CFData)?.takeRetainedValue(),
let subrole = try? axUiElement.subrole(),
[kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) {
axWindows.append(axUiElement)
}
}
return axWindows
}

static func isActualWindow(_ app: Application, _ wid: CGWindowID, _ level: CGWindowLevel, _ title: String?, _ subrole: String?, _ role: String?, _ size: CGSize?) -> Bool {
// Some non-windows have title: nil (e.g. some OS elements)
// Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips)
Expand Down Expand Up @@ -274,102 +393,16 @@ extension AXUIElement {
return (app.bundleIdentifier?.hasPrefix("com.autodesk.AutoCAD") ?? false) && subrole == kAXDocumentWindowSubrole
}

func position() throws -> CGPoint? {
return try value(kAXPositionAttribute, CGPoint.zero, .cgPoint)
}

func size() throws -> CGSize? {
return try value(kAXSizeAttribute, CGSize.zero, .cgSize)
}

func title() throws -> String? {
return try attribute(kAXTitleAttribute, String.self)
}

// periphery:ignore
func parent() throws -> AXUIElement? {
return try attribute(kAXParentAttribute, AXUIElement.self)
}

func children() throws -> [AXUIElement]? {
return try attribute(kAXChildrenAttribute, [AXUIElement].self)
}

/// we combine both the normal approach and brute-force to get all possible windows
/// with only normal approach: we miss other-Spaces windows
/// with only brute-force approach: we miss windows when the app launches (e.g. launch Note.app: first window is not found by brute-force)
func allWindows(_ pid: pid_t) throws -> [AXUIElement] {
let aWindows = try! windows()
let bWindows = AXUIElement.windowsByBruteForce(pid)
return Array(Set(aWindows + bWindows))
}

/// doesn't return windows on other Spaces
/// use windowsByBruteForce if you want those
func windows() throws -> [AXUIElement] {
let windows = try attribute(kAXWindowsAttribute, [AXUIElement].self)
if let windows,
!windows.isEmpty {
// bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login)
let uniqueWindows = Array(Set(windows))
if !uniqueWindows.isEmpty {
return uniqueWindows
}
}
return []
}

/// brute-force getting the windows of a process by iterating over AXUIElementID one by one
private static func windowsByBruteForce(_ pid: pid_t) -> [AXUIElement] {
// we use this to call _AXUIElementCreateWithRemoteToken; we reuse the object for performance
// tests showed that this remoteToken is 20 bytes: 4 + 4 + 4 + 8; the order of bytes matters
var remoteToken = Data(count: 20)
remoteToken.replaceSubrange(0..<4, with: withUnsafeBytes(of: pid) { Data($0) })
remoteToken.replaceSubrange(4..<8, with: withUnsafeBytes(of: Int32(0)) { Data($0) })
remoteToken.replaceSubrange(8..<12, with: withUnsafeBytes(of: Int32(0x636f636f)) { Data($0) })
var axWindows = [AXUIElement]()
// we iterate to 1000 as a tradeoff between performance, and missing windows of long-lived processes
for axUiElementId: AXUIElementID in 0..<1000 {
remoteToken.replaceSubrange(12..<20, with: withUnsafeBytes(of: axUiElementId) { Data($0) })
if let axUiElement = _AXUIElementCreateWithRemoteToken(remoteToken as CFData)?.takeRetainedValue(),
let subrole = try? axUiElement.subrole(),
[kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) {
axWindows.append(axUiElement)
}
}
return axWindows
}

func isMinimized() throws -> Bool {
return try attribute(kAXMinimizedAttribute, Bool.self) == true
}

func isFullscreen() throws -> Bool {
return try attribute(kAXFullscreenAttribute, Bool.self) == true
}

func focusedWindow() throws -> AXUIElement? {
return try attribute(kAXFocusedWindowAttribute, AXUIElement.self)
}

func role() throws -> String? {
return try attribute(kAXRoleAttribute, String.self)
}

func subrole() throws -> String? {
return try attribute(kAXSubroleAttribute, String.self)
}

func appIsRunning() throws -> Bool? {
return try attribute(kAXIsApplicationRunningAttribute, Bool.self)
func focusWindow() {
performAction(kAXRaiseAction)
}

func closeButton() throws -> AXUIElement? {
return try attribute(kAXCloseButtonAttribute, AXUIElement.self)
func setAttribute(_ key: String, _ value: Any) {
AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef)
}

func focusWindow() {
performAction(kAXRaiseAction)
func performAction(_ action: String) {
AXUIElementPerformAction(self, action as CFString)
}

func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws {
Expand All @@ -380,14 +413,6 @@ extension AXUIElement {
throw AxError.runtimeError
}
}

func setAttribute(_ key: String, _ value: Any) {
AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef)
}

func performAction(_ action: String) {
AXUIElementPerformAction(self, action as CFString)
}
}

enum AxError: Error {
Expand Down
2 changes: 1 addition & 1 deletion src/api-wrappers/CGWindowID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ extension CGWindowID {
cgProperty("kCGSWindowTitle", String.self)
}

func level() throws -> CGWindowLevel {
func level() -> CGWindowLevel {
var level = CGWindowLevel(0)
CGSGetWindowLevel(CGS_CONNECTION, self, &level)
return level
Expand Down
10 changes: 3 additions & 7 deletions src/logic/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,11 @@ class Application: NSObject {
var atLeastOneActualWindow = false
let axWindows = try self.axUiElement!.allWindows(self.pid)
for axWindow in axWindows {
if let wid = try axWindow.cgWindowId() {
let title = try axWindow.title()
let subrole = try axWindow.subrole()
let role = try axWindow.role()
if let wid = try axWindow.cgWindowId(),
let (title, role, subrole, isMinimized, isFullscreen) = try axWindow.windowAttributes() {
let size = try axWindow.size()
let level = try wid.level()
let level = wid.level()
if AXUIElement.isActualWindow(self, wid, level, title, subrole, role, size) {
let isFullscreen = try axWindow.isFullscreen()
let isMinimized = try axWindow.isMinimized()
let position = try axWindow.position()
atLeastOneActualWindow = true
DispatchQueue.main.async { [weak self] in
Expand Down
1 change: 0 additions & 1 deletion src/logic/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ class Window {
]

init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ axTitle: String?, _ isFullscreen: Bool, _ isMinimized: Bool, _ position: CGPoint?, _ size: CGSize?) {
// TODO: make a efficient batched AXUIElementCopyMultipleAttributeValues call once for each window, and store the values
self.axUiElement = axUiElement
self.application = application
cgWindowId = wid
Expand Down
Loading

0 comments on commit 9d78700

Please sign in to comment.