Skip to content

Commit d144476

Browse files
committed
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
1 parent 8d833f5 commit d144476

13 files changed

Lines changed: 417 additions & 252 deletions

alt-tab-macos.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
D04BA34AC850A273AB288B1E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA3B51D05213404938366 /* Localizable.strings */; };
3535
D04BA3744F48116DF4252B19 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA02355EB28D639F854DF /* Localizable.strings */; };
3636
D04BA3C24F4F644EA91DE38C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA717693DA18CB74BAED1 /* Localizable.strings */; };
37-
D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */; };
37+
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */; };
3838
D04BA40CC1415DA69CCE5D89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA17FC84640580894400E /* InfoPlist.strings */; };
3939
D04BA4575B13F1A148C108E2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA459B8804ABFBDA50663 /* InfoPlist.strings */; };
4040
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACABD048E62EBE4576CC /* DebugProfile.swift */; };
@@ -216,7 +216,7 @@
216216
D04BAB51808B6118EB00DFC7 /* mstile-150x150.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "mstile-150x150.png"; sourceTree = "<group>"; };
217217
D04BAB6652494D7575057E86 /* 14 windows - 3 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "14 windows - 3 lines.jpg"; sourceTree = "<group>"; };
218218
D04BAB703998DAD0EC9A6F4A /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = "<group>"; };
219-
D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueues.swift; sourceTree = "<group>"; };
219+
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundWork.swift; sourceTree = "<group>"; };
220220
D04BAB7714DEDEA0A53AC3ED /* main.scss */ = {isa = PBXFileReference; lastKnownFileType = file.scss; path = main.scss; sourceTree = "<group>"; };
221221
D04BAB7AC7316FA7117B071E /* pt-BR */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = Localizable.strings; sourceTree = "<group>"; };
222222
D04BAB8A94DA69A6B5008AE5 /* Pipfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Pipfile; sourceTree = "<group>"; };
@@ -319,7 +319,7 @@
319319
D04BA015A45DE7AFDC9794FE /* Window.swift */,
320320
D04BA10777505D8A67ABD186 /* Application.swift */,
321321
D04BA282BB16C1554595A968 /* Applications.swift */,
322-
D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */,
322+
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
323323
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
324324
D04BAC8857A527C2E15D6598 /* events */,
325325
);
@@ -1013,7 +1013,7 @@
10131013
D04BA6187A91A847844B6ABB /* Window.swift in Sources */,
10141014
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */,
10151015
D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */,
1016-
D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */,
1016+
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */,
10171017
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */,
10181018
D04BAB68B7B8D1B548BC3AD5 /* App.swift in Sources */,
10191019
D04BAB048DE698E013577C51 /* ThumbnailsPanel.swift in Sources */,

src/api-wrappers/AXUIElement.swift

Lines changed: 79 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,135 @@
11
import Cocoa
22

33
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+
412
static let normalLevel = CGWindowLevelForKey(.normalWindow)
513

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? {
725
var id = CGWindowID(0)
8-
_AXUIElementGetWindow(self, &id)
9-
return id
26+
return try axCallWhichCanThrow(_AXUIElementGetWindow(self, &id), &id)
1027
}
1128

12-
func pid() -> pid_t {
29+
func pid() throws -> pid_t? {
1330
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
1637
}
1738

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 {
1949
// Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked)
2050
// Some non-windows have title: nil (e.g. some OS elements)
2151
// Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips)
2252
// Minimized windows or windows of a hidden app have subrole "AXDialog"
2353
// Activity Monitor main window subrole is "AXDialog" for a brief moment at launch; it then becomes "AXStandardWindow"
2454
// 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()) ||
2860
// All Steam windows have subrole = AXUnknown
2961
// 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))
3363
}
3464

35-
func isOnNormalLevel() -> Bool {
36-
let level: CGWindowLevel = cgWindowId().level()
65+
func isOnNormalLevel(_ wid: CGWindowID) -> Bool {
66+
let level: CGWindowLevel = wid.level()
3767
return level == AXUIElement.normalLevel
3868
}
3969

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)
4672
}
4773

48-
func windows() -> [AXUIElement]? {
49-
return attribute(kAXWindowsAttribute, [AXUIElement].self)
74+
func title() throws -> String? {
75+
return try attribute(kAXTitleAttribute, String.self)
5076
}
5177

52-
func isMinimized() -> Bool {
53-
return attribute(kAXMinimizedAttribute, Bool.self) == true
78+
func parent() throws -> AXUIElement? {
79+
return try attribute(kAXParentAttribute, AXUIElement.self)
5480
}
5581

56-
func isHidden() -> Bool {
57-
return attribute(kAXHiddenAttribute, Bool.self) == true
82+
func windows() throws -> [AXUIElement]? {
83+
return try attribute(kAXWindowsAttribute, [AXUIElement].self)
5884
}
5985

60-
func isFullScreen() -> Bool {
61-
return attribute(kAXFullscreenAttribute, Bool.self) == true
86+
func isMinimized() throws -> Bool {
87+
return try attribute(kAXMinimizedAttribute, Bool.self) == true
6288
}
6389

64-
func focusedWindow() -> AXUIElement? {
65-
return attribute(kAXFocusedWindowAttribute, AXUIElement.self)
90+
func isFullscreen() throws -> Bool {
91+
return try
92+
attribute(kAXFullscreenAttribute, Bool.self) == true
6693
}
6794

68-
func role() -> String? {
69-
return attribute(kAXRoleAttribute, String.self)
95+
func focusedWindow() throws -> AXUIElement? {
96+
return try attribute(kAXFocusedWindowAttribute, AXUIElement.self)
7097
}
7198

72-
func subrole() -> String? {
73-
return attribute(kAXSubroleAttribute, String.self)
99+
func role() throws -> String? {
100+
return try attribute(kAXRoleAttribute, String.self)
74101
}
75102

76-
func closeButton() -> AXUIElement {
77-
return attribute(kAXCloseButtonAttribute, AXUIElement.self)!
103+
func subrole() throws -> String? {
104+
return try attribute(kAXSubroleAttribute, String.self)
78105
}
79106

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)
98109
}
99110

100111
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)
115113
}
116114

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)
118117
if result == .success || result == .notificationAlreadyRegistered {
119-
DispatchQueue.main.async { () -> () in
120-
callback?()
121-
}
118+
callback?()
122119
} 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
126121
}
127122
}
128123

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)
136126
}
137127

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)
145130
}
146131
}
132+
133+
enum AxError: Error {
134+
case runtimeError
135+
}

src/api-wrappers/HelperExtensions.swift

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,11 @@ extension Collection {
77
}
88
}
99

10-
// removing an objc KVO observer if there is none throws an exception
11-
extension NSObject {
12-
func safeRemoveObserver(_ observer: NSObject, _ key: String) {
13-
guard observationInfo != nil else { return }
14-
removeObserver(observer, forKeyPath: key)
15-
}
16-
}
17-
1810
extension Array where Element == Window {
19-
func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? {
11+
func firstIndexThatMatches(_ element: AXUIElement, _ wid: CGWindowID?) -> Self.Index? {
2012
// the window can be deallocated by the OS, in which case its `CGWindowID` will be `-1`
2113
// we check for equality both on the AXUIElement, and the CGWindowID, in order to catch all scenarios
22-
return firstIndex(where: { $0.axUiElement == element || ($0.cgWindowId != -1 && $0.cgWindowId == element.cgWindowId()) })
14+
return firstIndex { $0.axUiElement == element || ($0.cgWindowId != -1 && $0.cgWindowId == wid) }
2315
}
2416

2517
mutating func insertAndScaleRecycledPool(_ elements: [Element], at i: Int) {
@@ -50,9 +42,11 @@ extension Array {
5042
func forEachAsync(fn: @escaping (Element) -> Void) {
5143
let group = DispatchGroup()
5244
for element in self {
53-
group.enter()
54-
DispatchQueue.global(qos: .userInteractive).async(group: group) {
45+
BackgroundWork.globalSemaphore.wait()
46+
BackgroundWork.uiDisplayQueue.async(group: group) {
47+
group.enter()
5548
fn(element)
49+
BackgroundWork.globalSemaphore.signal()
5650
group.leave()
5751
}
5852
}

0 commit comments

Comments
 (0)