Skip to content

Commit

Permalink
fix: rework all multi-threading to handle complex scenarios
Browse files Browse the repository at this point in the history
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
lwouis committed May 25, 2020
1 parent 4eb5657 commit e0f6375
Show file tree
Hide file tree
Showing 13 changed files with 416 additions and 251 deletions.
8 changes: 4 additions & 4 deletions alt-tab-macos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
D04BA34AC850A273AB288B1E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA3B51D05213404938366 /* Localizable.strings */; };
D04BA3744F48116DF4252B19 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA02355EB28D639F854DF /* Localizable.strings */; };
D04BA3C24F4F644EA91DE38C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA717693DA18CB74BAED1 /* Localizable.strings */; };
D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */; };
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */; };
D04BA40CC1415DA69CCE5D89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA17FC84640580894400E /* InfoPlist.strings */; };
D04BA4575B13F1A148C108E2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA459B8804ABFBDA50663 /* InfoPlist.strings */; };
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACABD048E62EBE4576CC /* DebugProfile.swift */; };
Expand Down Expand Up @@ -216,7 +216,7 @@
D04BAB51808B6118EB00DFC7 /* mstile-150x150.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "mstile-150x150.png"; sourceTree = "<group>"; };
D04BAB6652494D7575057E86 /* 14 windows - 3 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "14 windows - 3 lines.jpg"; sourceTree = "<group>"; };
D04BAB703998DAD0EC9A6F4A /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = "<group>"; };
D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueues.swift; sourceTree = "<group>"; };
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundWork.swift; sourceTree = "<group>"; };
D04BAB7714DEDEA0A53AC3ED /* main.scss */ = {isa = PBXFileReference; lastKnownFileType = file.scss; path = main.scss; sourceTree = "<group>"; };
D04BAB7AC7316FA7117B071E /* pt-BR */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = Localizable.strings; sourceTree = "<group>"; };
D04BAB8A94DA69A6B5008AE5 /* Pipfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Pipfile; sourceTree = "<group>"; };
Expand Down Expand Up @@ -319,7 +319,7 @@
D04BA015A45DE7AFDC9794FE /* Window.swift */,
D04BA10777505D8A67ABD186 /* Application.swift */,
D04BA282BB16C1554595A968 /* Applications.swift */,
D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */,
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
D04BAC8857A527C2E15D6598 /* events */,
);
Expand Down Expand Up @@ -1013,7 +1013,7 @@
D04BA6187A91A847844B6ABB /* Window.swift in Sources */,
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */,
D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */,
D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */,
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */,
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */,
D04BAB68B7B8D1B548BC3AD5 /* App.swift in Sources */,
D04BAB048DE698E013577C51 /* ThumbnailsPanel.swift in Sources */,
Expand Down
169 changes: 79 additions & 90 deletions src/api-wrappers/AXUIElement.swift
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
}
18 changes: 6 additions & 12 deletions src/api-wrappers/HelperExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,11 @@ extension Collection {
}
}

// removing an objc KVO observer if there is none throws an exception
extension NSObject {
func safeRemoveObserver(_ observer: NSObject, _ key: String) {
guard observationInfo != nil else { return }
removeObserver(observer, forKeyPath: key)
}
}

extension Array where Element == Window {
func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? {
func firstIndexThatMatches(_ element: AXUIElement, _ wid: CGWindowID?) -> Self.Index? {
// the window can be deallocated by the OS, in which case its `CGWindowID` will be `-1`
// we check for equality both on the AXUIElement, and the CGWindowID, in order to catch all scenarios
return firstIndex(where: { $0.axUiElement == element || ($0.cgWindowId != -1 && $0.cgWindowId == element.cgWindowId()) })
return firstIndex { $0.axUiElement == element || ($0.cgWindowId != -1 && $0.cgWindowId == wid) }
}

mutating func insertAndScaleRecycledPool(_ elements: [Element], at i: Int) {
Expand Down Expand Up @@ -50,9 +42,11 @@ extension Array {
func forEachAsync(fn: @escaping (Element) -> Void) {
let group = DispatchGroup()
for element in self {
group.enter()
DispatchQueue.global(qos: .userInteractive).async(group: group) {
BackgroundWork.globalSemaphore.wait()
BackgroundWork.uiDisplayQueue.async(group: group) {
group.enter()
fn(element)
BackgroundWork.globalSemaphore.signal()
group.leave()
}
}
Expand Down
Loading

0 comments on commit e0f6375

Please sign in to comment.