|
1 | 1 | import Cocoa |
2 | 2 |
|
3 | 3 | extension AXUIElement { |
| 4 | + static let globalTimeoutInSeconds = Float(120) |
| 5 | + |
| 6 | + // default timeout for AX calls is 6s. We increase it in order to avoid retrying every 6s, thus saving resources |
| 7 | + static func setGlobalTimeout() { |
| 8 | + // we add 5s to make sure to not do an extra retry |
| 9 | + AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalTimeoutInSeconds + 5) |
| 10 | + } |
| 11 | + |
4 | 12 | static let normalLevel = CGWindowLevelForKey(.normalWindow) |
5 | 13 |
|
6 | | - func cgWindowId() -> CGWindowID { |
| 14 | + func axCallWhichCanThrow<T>(_ result: AXError, _ successValue: inout T) throws -> T? { |
| 15 | + switch result { |
| 16 | + case .success: return successValue |
| 17 | + // .cannotComplete can happen if the app is unresponsive; we throw in that case to retry until the call succeeds |
| 18 | + case .cannotComplete: throw AxError.runtimeError |
| 19 | + // for other errors it's pointless to retry |
| 20 | + default: return nil |
| 21 | + } |
| 22 | + } |
| 23 | + |
| 24 | + func cgWindowId() throws -> CGWindowID? { |
7 | 25 | var id = CGWindowID(0) |
8 | | - _AXUIElementGetWindow(self, &id) |
9 | | - return id |
| 26 | + return try axCallWhichCanThrow(_AXUIElementGetWindow(self, &id), &id) |
10 | 27 | } |
11 | 28 |
|
12 | | - func pid() -> pid_t { |
| 29 | + func pid() throws -> pid_t? { |
13 | 30 | var pid = pid_t(0) |
14 | | - AXUIElementGetPid(self, &pid) |
15 | | - return pid |
| 31 | + return try axCallWhichCanThrow(AXUIElementGetPid(self, &pid), &pid) |
| 32 | + } |
| 33 | + |
| 34 | + func attribute<T>(_ key: String, _ type: T.Type) throws -> T? { |
| 35 | + var value: AnyObject? |
| 36 | + return try axCallWhichCanThrow(AXUIElementCopyAttributeValue(self, key as CFString, &value), &value) as? T |
16 | 37 | } |
17 | 38 |
|
18 | | - func isActualWindow(_ bundleIdentifier: String?) -> Bool { |
| 39 | + private func value<T>(_ key: String, _ target: T, _ type: AXValueType) throws -> T? { |
| 40 | + if let a = try attribute(key, AXValue.self) { |
| 41 | + var value = target |
| 42 | + AXValueGetValue(a, type, &value) |
| 43 | + return value |
| 44 | + } |
| 45 | + return nil |
| 46 | + } |
| 47 | + |
| 48 | + func isActualWindow(_ bundleIdentifier: String?) throws -> Bool { |
19 | 49 | // Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked) |
20 | 50 | // Some non-windows have title: nil (e.g. some OS elements) |
21 | 51 | // Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips) |
22 | 52 | // Minimized windows or windows of a hidden app have subrole "AXDialog" |
23 | 53 | // Activity Monitor main window subrole is "AXDialog" for a brief moment at launch; it then becomes "AXStandardWindow" |
24 | 54 | // CGWindowLevel == .normalWindow helps filter out iStats Pro and other top-level pop-overs |
25 | | - let subrole_ = subrole() |
26 | | - return cgWindowId() != 0 && subrole_ != nil && |
27 | | - (["AXStandardWindow", "AXDialog"].contains(subrole_) || |
| 55 | + let wid = try cgWindowId() |
| 56 | + return try wid != nil && wid != 0 && |
| 57 | + // don't show floating windows |
| 58 | + isOnNormalLevel(wid!) && |
| 59 | + (["AXStandardWindow", "AXDialog"].contains(subrole()) || |
28 | 60 | // All Steam windows have subrole = AXUnknown |
29 | 61 | // some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly |
30 | | - (bundleIdentifier == "com.valvesoftware.steam" && title() != "" && role() != nil)) && |
31 | | - // don't show floating windows |
32 | | - isOnNormalLevel() |
| 62 | + (bundleIdentifier == "com.valvesoftware.steam" && title() != "" && role() != nil)) |
33 | 63 | } |
34 | 64 |
|
35 | | - func isOnNormalLevel() -> Bool { |
36 | | - let level: CGWindowLevel = cgWindowId().level() |
| 65 | + func isOnNormalLevel(_ wid: CGWindowID) -> Bool { |
| 66 | + let level: CGWindowLevel = wid.level() |
37 | 67 | return level == AXUIElement.normalLevel |
38 | 68 | } |
39 | 69 |
|
40 | | - func position() -> CGPoint? { |
41 | | - return value(kAXPositionAttribute, CGPoint.zero, .cgPoint) |
42 | | - } |
43 | | - |
44 | | - func title() -> String? { |
45 | | - return attribute(kAXTitleAttribute, String.self) |
| 70 | + func position() throws -> CGPoint? { |
| 71 | + return try value(kAXPositionAttribute, CGPoint.zero, .cgPoint) |
46 | 72 | } |
47 | 73 |
|
48 | | - func windows() -> [AXUIElement]? { |
49 | | - return attribute(kAXWindowsAttribute, [AXUIElement].self) |
| 74 | + func title() throws -> String? { |
| 75 | + return try attribute(kAXTitleAttribute, String.self) |
50 | 76 | } |
51 | 77 |
|
52 | | - func isMinimized() -> Bool { |
53 | | - return attribute(kAXMinimizedAttribute, Bool.self) == true |
| 78 | + func parent() throws -> AXUIElement? { |
| 79 | + return try attribute(kAXParentAttribute, AXUIElement.self) |
54 | 80 | } |
55 | 81 |
|
56 | | - func isHidden() -> Bool { |
57 | | - return attribute(kAXHiddenAttribute, Bool.self) == true |
| 82 | + func windows() throws -> [AXUIElement]? { |
| 83 | + return try attribute(kAXWindowsAttribute, [AXUIElement].self) |
58 | 84 | } |
59 | 85 |
|
60 | | - func isFullScreen() -> Bool { |
61 | | - return attribute(kAXFullscreenAttribute, Bool.self) == true |
| 86 | + func isMinimized() throws -> Bool { |
| 87 | + return try attribute(kAXMinimizedAttribute, Bool.self) == true |
62 | 88 | } |
63 | 89 |
|
64 | | - func focusedWindow() -> AXUIElement? { |
65 | | - return attribute(kAXFocusedWindowAttribute, AXUIElement.self) |
| 90 | + func isFullscreen() throws -> Bool { |
| 91 | + return try |
| 92 | + attribute(kAXFullscreenAttribute, Bool.self) == true |
66 | 93 | } |
67 | 94 |
|
68 | | - func role() -> String? { |
69 | | - return attribute(kAXRoleAttribute, String.self) |
| 95 | + func focusedWindow() throws -> AXUIElement? { |
| 96 | + return try attribute(kAXFocusedWindowAttribute, AXUIElement.self) |
70 | 97 | } |
71 | 98 |
|
72 | | - func subrole() -> String? { |
73 | | - return attribute(kAXSubroleAttribute, String.self) |
| 99 | + func role() throws -> String? { |
| 100 | + return try attribute(kAXRoleAttribute, String.self) |
74 | 101 | } |
75 | 102 |
|
76 | | - func closeButton() -> AXUIElement { |
77 | | - return attribute(kAXCloseButtonAttribute, AXUIElement.self)! |
| 103 | + func subrole() throws -> String? { |
| 104 | + return try attribute(kAXSubroleAttribute, String.self) |
78 | 105 | } |
79 | 106 |
|
80 | | - func closeWindow() { |
81 | | - if isFullScreen() { |
82 | | - AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef) |
83 | | - } |
84 | | - AXUIElementPerformAction(closeButton(), kAXPressAction as CFString) |
85 | | - } |
86 | | - |
87 | | - func minDeminWindow() { |
88 | | - if isFullScreen() { |
89 | | - AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef) |
90 | | - // minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over |
91 | | - DispatchQueues.accessibilityCommands.asyncAfter(deadline: .now() + .milliseconds(1000)) { [weak self] in |
92 | | - guard let self = self else { return } |
93 | | - AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, true as CFTypeRef) |
94 | | - } |
95 | | - } else { |
96 | | - AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, !isMinimized() as CFTypeRef) |
97 | | - } |
| 107 | + func closeButton() throws -> AXUIElement? { |
| 108 | + return try attribute(kAXCloseButtonAttribute, AXUIElement.self) |
98 | 109 | } |
99 | 110 |
|
100 | 111 | func focusWindow() { |
101 | | - AXUIElementPerformAction(self, kAXRaiseAction as CFString) |
102 | | - } |
103 | | - |
104 | | - func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ startTime: DispatchTime = DispatchTime.now()) { |
105 | | - DispatchQueue.global(qos: .userInteractive).async { [weak self] () -> () in |
106 | | - guard let self = self else { return } |
107 | | - // some apps return .isFinishedLaunching = true but will return .cannotComplete when we try to subscribe to them |
108 | | - // this happens for example when apps launch and have heavy loading to do (e.g. Gimp). |
109 | | - // we have no way to know if they are one day going to be letting us subscribe, so we timeout after 2 min |
110 | | - let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000 |
111 | | - if timePassedInSeconds > 120 { return } |
112 | | - let result = AXObserverAddNotification(axObserver, self, notification as CFString, pointer) |
113 | | - self.handleSubscriptionAttempt(result, axObserver, notification, pointer, callback, runningApplication, wid, startTime) |
114 | | - } |
| 112 | + performAction(kAXRaiseAction) |
115 | 113 | } |
116 | 114 |
|
117 | | - func handleSubscriptionAttempt(_ result: AXError, _ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)?, _ runningApplication: NSRunningApplication?, _ wid: CGWindowID?, _ startTime: DispatchTime) -> Void { |
| 115 | + func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ startTime: DispatchTime = DispatchTime.now()) throws { |
| 116 | + let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil) |
118 | 117 | if result == .success || result == .notificationAlreadyRegistered { |
119 | | - DispatchQueue.main.async { () -> () in |
120 | | - callback?() |
121 | | - } |
| 118 | + callback?() |
122 | 119 | } else if result != .notificationUnsupported && result != .notImplemented { |
123 | | - DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + .milliseconds(10), execute: { [weak self] in |
124 | | - self?.subscribeWithRetry(axObserver, notification, pointer, callback, runningApplication, wid, startTime) |
125 | | - }) |
| 120 | + throw AxError.runtimeError |
126 | 121 | } |
127 | 122 | } |
128 | 123 |
|
129 | | - private func attribute<T>(_ key: String, _ type: T.Type) -> T? { |
130 | | - var value: AnyObject? |
131 | | - let result = AXUIElementCopyAttributeValue(self, key as CFString, &value) |
132 | | - if result == .success, let value = value as? T { |
133 | | - return value |
134 | | - } |
135 | | - return nil |
| 124 | + func setAttribute(_ key: String, _ value: Any) { |
| 125 | + AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef) |
136 | 126 | } |
137 | 127 |
|
138 | | - private func value<T>(_ key: String, _ target: T, _ type: AXValueType) -> T? { |
139 | | - if let a = attribute(key, AXValue.self) { |
140 | | - var value = target |
141 | | - AXValueGetValue(a, type, &value) |
142 | | - return value |
143 | | - } |
144 | | - return nil |
| 128 | + func performAction(_ action: String) { |
| 129 | + AXUIElementPerformAction(self, action as CFString) |
145 | 130 | } |
146 | 131 | } |
| 132 | + |
| 133 | +enum AxError: Error { |
| 134 | + case runtimeError |
| 135 | +} |
0 commit comments