feat: improve performance and lower resources consumption

avoid unnecessary AX calls and batch calls when possible
This commit is contained in:
lwouis committed 2025-02-13 11:49:29 +01:00
1 parent c72fedbb30
commit 9d78700ab7
5 files changed
+175 -149

No files matched your search

+127 -102
View File
@@ -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
@@ -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)
@@ -274,104 +393,18 @@ 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 closeButton() throws -> AXUIElement? {
return try attribute(kAXCloseButtonAttribute, AXUIElement.self)
}
func focusWindow() {
performAction(kAXRaiseAction)
}
func setAttribute(_ key: String, _ value: Any) {
AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef)
}
func performAction(_ action: String) {
AXUIElementPerformAction(self, action as CFString)
}
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws {
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
if result == .success || result == .notificationAlreadyRegistered {
@@ -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 {
+1 -1
View File
@@ -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
+3 -7
View File
@@ -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
-1
View File
@@ -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
+44 -38
View File
@@ -65,20 +65,16 @@ fileprivate func applicationHiddenOrShown(_ pid: pid_t, _ type: String) throws {
}
fileprivate func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws {
if let wid = try element.cgWindowId() {
let axTitle = try element.title()
let subrole = try element.subrole()
let role = try element.role()
let isFullscreen = try element.isFullscreen()
let isMinimized = try element.isMinimized()
let level = try wid.level()
if let wid = try element.cgWindowId(),
let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
let level = wid.level()
DispatchQueue.main.async {
if let app = Applications.find(pid), NSRunningApplication(processIdentifier: pid) != nil {
if (!Windows.list.contains { $0.isEqualRobust(element, wid) }) &&
AXUIElement.isActualWindow(app, wid, level, axTitle, subrole, role, size) {
let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position, size)
AXUIElement.isActualWindow(app, wid, level, title, subrole, role, size) {
let window = Window(element, app, wid, title, isFullscreen, isMinimized, position, size)
Windows.appendAndUpdateFocus(window)
Windows.cycleFocusedWindowIndex(1)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
@@ -94,27 +90,31 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro
// photoshop will focus a window *after* you focus another app
// we check that a focused window happens within an active app
if runningApp.isActive {
let axTitle = try element.title()
let subrole = try element.subrole()
let role = try element.role()
let isFullscreen = try element.isFullscreen()
let isMinimized = try element.isMinimized()
let level = try wid.level()
let position = try element.position()
let size = try element.size()
DispatchQueue.main.async {
guard let app = Applications.find(pid) else { return }
// if the window is shown by alt-tab, we mark her as focused for this app
// if the window is shown by alt-tab, we mark it as focused for this app
// this avoids issues with dialogs, quicklook, etc (see scenarios from #1044 and #2003)
if let w = (Windows.list.first { $0.isEqualRobust(element, wid) }) {
app.focusedWindow = w
}
if let windows = Windows.updateLastFocus(element, wid) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
} else if AXUIElement.isActualWindow(app, wid, level, axTitle, subrole, role, size) {
let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position, size)
Windows.appendAndUpdateFocus(window)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
} else {
AXUIElement.retryAxCallUntilTimeout {
if let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
let level = wid.level()
DispatchQueue.main.async {
if (!Windows.list.contains { $0.isEqualRobust(element, wid) }),
AXUIElement.isActualWindow(app, wid, level, title, subrole, role, size) {
let window = Window(element, app, wid, title, isFullscreen, isMinimized, position, size)
Windows.appendAndUpdateFocus(window)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
}
}
}
}
@@ -158,11 +158,14 @@ fileprivate func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ ty
fileprivate func windowTitleChanged(_ element: AXUIElement) throws {
if let wid = try element.cgWindowId() {
AXUIElement.retryAxCallUntilTimeoutDebounced(.windowTitleChanged, wid) {
let newTitle = try element.title()
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), newTitle != window.title {
window.title = window.bestEffortTitle(newTitle)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), title != window.title {
window.title = window.bestEffortTitle(title)
window.isMinimized = isMinimized
window.isFullscreen = isFullscreen
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
}
@@ -178,18 +181,21 @@ fileprivate func windowResizedOrMoved(_ element: AXUIElement) throws {
}
func updateWindowSizeAndPositionAndFullscreen(_ element: AXUIElement, _ wid: CGWindowID, _ window: Window?) throws {
let isFullscreen = try element.isFullscreen()
let size = try element.size()
let position = try element.position()
DispatchQueue.main.async {
if let window = (window != nil ? window : (Windows.list.first { $0.isEqualRobust(element, wid) })) {
window.size = size
window.position = position
if window.isFullscreen != isFullscreen {
window.isFullscreen = isFullscreen
App.app.checkIfShortcutsShouldBeDisabled(window, nil)
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
let size = try element.size()
let position = try element.position()
DispatchQueue.main.async {
if let window = (window != nil ? window : (Windows.list.first { $0.isEqualRobust(element, wid) })) {
window.title = title
window.size = size
window.position = position
window.isMinimized = isMinimized
if window.isFullscreen != isFullscreen {
window.isFullscreen = isFullscreen
App.app.checkIfShortcutsShouldBeDisabled(window, nil)
}
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}