-
-
Notifications
You must be signed in to change notification settings - Fork 354
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: rework all multi-threading to handle complex scenarios
BREAKING CHANGE: this rework should fix all sorts of issues when OS events happen in parallel: new windows, new apps, user shortcuts, etc. Here are example of use-cases that should work great now, without, and very quickly: * AltTab is open and an app/window is launched/quit * A window is minimized/deminimized, and while the animation is playing, the user invokes AltTab * An app starts and takes a long time to boot (e.g. Gimp) * An app becomes unresponsive, yet AltTab is unaffected and remains interactive while still processing the state of the window while its parent app finally stops being frozen closes #348, closes #157, closes #342, closes #93
- Loading branch information
Showing
13 changed files
with
416 additions
and
251 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,146 +1,135 @@ | ||
import Cocoa | ||
|
||
extension AXUIElement { | ||
static let globalTimeoutInSeconds = Float(120) | ||
|
||
// default timeout for AX calls is 6s. We increase it in order to avoid retrying every 6s, thus saving resources | ||
static func setGlobalTimeout() { | ||
// we add 5s to make sure to not do an extra retry | ||
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalTimeoutInSeconds + 5) | ||
} | ||
|
||
static let normalLevel = CGWindowLevelForKey(.normalWindow) | ||
|
||
func cgWindowId() -> CGWindowID { | ||
func axCallWhichCanThrow<T>(_ result: AXError, _ successValue: inout T) throws -> T? { | ||
switch result { | ||
case .success: return successValue | ||
// .cannotComplete can happen if the app is unresponsive; we throw in that case to retry until the call succeeds | ||
case .cannotComplete: throw AxError.runtimeError | ||
// for other errors it's pointless to retry | ||
default: return nil | ||
} | ||
} | ||
|
||
func cgWindowId() throws -> CGWindowID? { | ||
var id = CGWindowID(0) | ||
_AXUIElementGetWindow(self, &id) | ||
return id | ||
return try axCallWhichCanThrow(_AXUIElementGetWindow(self, &id), &id) | ||
} | ||
|
||
func pid() -> pid_t { | ||
func pid() throws -> pid_t? { | ||
var pid = pid_t(0) | ||
AXUIElementGetPid(self, &pid) | ||
return pid | ||
return try axCallWhichCanThrow(AXUIElementGetPid(self, &pid), &pid) | ||
} | ||
|
||
func attribute<T>(_ key: String, _ type: T.Type) throws -> T? { | ||
var value: AnyObject? | ||
return try axCallWhichCanThrow(AXUIElementCopyAttributeValue(self, key as CFString, &value), &value) as? T | ||
} | ||
|
||
func isActualWindow(_ bundleIdentifier: String?) -> Bool { | ||
private func value<T>(_ key: String, _ target: T, _ type: AXValueType) throws -> T? { | ||
if let a = try attribute(key, AXValue.self) { | ||
var value = target | ||
AXValueGetValue(a, type, &value) | ||
return value | ||
} | ||
return nil | ||
} | ||
|
||
func isActualWindow(_ bundleIdentifier: String?) throws -> Bool { | ||
// Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked) | ||
// 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) | ||
// Minimized windows or windows of a hidden app have subrole "AXDialog" | ||
// Activity Monitor main window subrole is "AXDialog" for a brief moment at launch; it then becomes "AXStandardWindow" | ||
// CGWindowLevel == .normalWindow helps filter out iStats Pro and other top-level pop-overs | ||
let subrole_ = subrole() | ||
return cgWindowId() != 0 && subrole_ != nil && | ||
(["AXStandardWindow", "AXDialog"].contains(subrole_) || | ||
let wid = try cgWindowId() | ||
return try wid != nil && wid != 0 && | ||
// don't show floating windows | ||
isOnNormalLevel(wid!) && | ||
(["AXStandardWindow", "AXDialog"].contains(subrole()) || | ||
// All Steam windows have subrole = AXUnknown | ||
// some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly | ||
(bundleIdentifier == "com.valvesoftware.steam" && title() != "" && role() != nil)) && | ||
// don't show floating windows | ||
isOnNormalLevel() | ||
(bundleIdentifier == "com.valvesoftware.steam" && title() != "" && role() != nil)) | ||
} | ||
|
||
func isOnNormalLevel() -> Bool { | ||
let level: CGWindowLevel = cgWindowId().level() | ||
func isOnNormalLevel(_ wid: CGWindowID) -> Bool { | ||
let level: CGWindowLevel = wid.level() | ||
return level == AXUIElement.normalLevel | ||
} | ||
|
||
func position() -> CGPoint? { | ||
return value(kAXPositionAttribute, CGPoint.zero, .cgPoint) | ||
} | ||
|
||
func title() -> String? { | ||
return attribute(kAXTitleAttribute, String.self) | ||
func position() throws -> CGPoint? { | ||
return try value(kAXPositionAttribute, CGPoint.zero, .cgPoint) | ||
} | ||
|
||
func windows() -> [AXUIElement]? { | ||
return attribute(kAXWindowsAttribute, [AXUIElement].self) | ||
func title() throws -> String? { | ||
return try attribute(kAXTitleAttribute, String.self) | ||
} | ||
|
||
func isMinimized() -> Bool { | ||
return attribute(kAXMinimizedAttribute, Bool.self) == true | ||
func parent() throws -> AXUIElement? { | ||
return try attribute(kAXParentAttribute, AXUIElement.self) | ||
} | ||
|
||
func isHidden() -> Bool { | ||
return attribute(kAXHiddenAttribute, Bool.self) == true | ||
func windows() throws -> [AXUIElement]? { | ||
return try attribute(kAXWindowsAttribute, [AXUIElement].self) | ||
} | ||
|
||
func isFullScreen() -> Bool { | ||
return attribute(kAXFullscreenAttribute, Bool.self) == true | ||
func isMinimized() throws -> Bool { | ||
return try attribute(kAXMinimizedAttribute, Bool.self) == true | ||
} | ||
|
||
func focusedWindow() -> AXUIElement? { | ||
return attribute(kAXFocusedWindowAttribute, AXUIElement.self) | ||
func isFullscreen() throws -> Bool { | ||
return try | ||
attribute(kAXFullscreenAttribute, Bool.self) == true | ||
} | ||
|
||
func role() -> String? { | ||
return attribute(kAXRoleAttribute, String.self) | ||
func focusedWindow() throws -> AXUIElement? { | ||
return try attribute(kAXFocusedWindowAttribute, AXUIElement.self) | ||
} | ||
|
||
func subrole() -> String? { | ||
return attribute(kAXSubroleAttribute, String.self) | ||
func role() throws -> String? { | ||
return try attribute(kAXRoleAttribute, String.self) | ||
} | ||
|
||
func closeButton() -> AXUIElement { | ||
return attribute(kAXCloseButtonAttribute, AXUIElement.self)! | ||
func subrole() throws -> String? { | ||
return try attribute(kAXSubroleAttribute, String.self) | ||
} | ||
|
||
func closeWindow() { | ||
if isFullScreen() { | ||
AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef) | ||
} | ||
AXUIElementPerformAction(closeButton(), kAXPressAction as CFString) | ||
} | ||
|
||
func minDeminWindow() { | ||
if isFullScreen() { | ||
AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef) | ||
// minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over | ||
DispatchQueues.accessibilityCommands.asyncAfter(deadline: .now() + .milliseconds(1000)) { [weak self] in | ||
guard let self = self else { return } | ||
AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, true as CFTypeRef) | ||
} | ||
} else { | ||
AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, !isMinimized() as CFTypeRef) | ||
} | ||
func closeButton() throws -> AXUIElement? { | ||
return try attribute(kAXCloseButtonAttribute, AXUIElement.self) | ||
} | ||
|
||
func focusWindow() { | ||
AXUIElementPerformAction(self, kAXRaiseAction as CFString) | ||
} | ||
|
||
func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ startTime: DispatchTime = DispatchTime.now()) { | ||
DispatchQueue.global(qos: .userInteractive).async { [weak self] () -> () in | ||
guard let self = self else { return } | ||
// some apps return .isFinishedLaunching = true but will return .cannotComplete when we try to subscribe to them | ||
// this happens for example when apps launch and have heavy loading to do (e.g. Gimp). | ||
// we have no way to know if they are one day going to be letting us subscribe, so we timeout after 2 min | ||
let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000 | ||
if timePassedInSeconds > 120 { return } | ||
let result = AXObserverAddNotification(axObserver, self, notification as CFString, pointer) | ||
self.handleSubscriptionAttempt(result, axObserver, notification, pointer, callback, runningApplication, wid, startTime) | ||
} | ||
performAction(kAXRaiseAction) | ||
} | ||
|
||
func handleSubscriptionAttempt(_ result: AXError, _ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)?, _ runningApplication: NSRunningApplication?, _ wid: CGWindowID?, _ startTime: DispatchTime) -> Void { | ||
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ startTime: DispatchTime = DispatchTime.now()) throws { | ||
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil) | ||
if result == .success || result == .notificationAlreadyRegistered { | ||
DispatchQueue.main.async { () -> () in | ||
callback?() | ||
} | ||
callback?() | ||
} else if result != .notificationUnsupported && result != .notImplemented { | ||
DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + .milliseconds(10), execute: { [weak self] in | ||
self?.subscribeWithRetry(axObserver, notification, pointer, callback, runningApplication, wid, startTime) | ||
}) | ||
throw AxError.runtimeError | ||
} | ||
} | ||
|
||
private func attribute<T>(_ key: String, _ type: T.Type) -> T? { | ||
var value: AnyObject? | ||
let result = AXUIElementCopyAttributeValue(self, key as CFString, &value) | ||
if result == .success, let value = value as? T { | ||
return value | ||
} | ||
return nil | ||
func setAttribute(_ key: String, _ value: Any) { | ||
AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef) | ||
} | ||
|
||
private func value<T>(_ key: String, _ target: T, _ type: AXValueType) -> T? { | ||
if let a = attribute(key, AXValue.self) { | ||
var value = target | ||
AXValueGetValue(a, type, &value) | ||
return value | ||
} | ||
return nil | ||
func performAction(_ action: String) { | ||
AXUIElementPerformAction(self, action as CFString) | ||
} | ||
} | ||
|
||
enum AxError: Error { | ||
case runtimeError | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.