From b86c5cc50722329f05c7651659030dbe7df45690 Mon Sep 17 00:00:00 2001 From: Louis Pontoise Date: Sun, 24 May 2020 18:20:54 +0900 Subject: [PATCH] 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 --- alt-tab-macos.xcodeproj/project.pbxproj | 8 +- src/api-wrappers/AXUIElement.swift | 169 +++++++-------- src/api-wrappers/HelperExtensions.swift | 18 +- src/logic/Application.swift | 64 ++++-- src/logic/BackgroundWork.swift | 53 +++++ src/logic/DispatchQueues.swift | 7 - src/logic/Screen.swift | 3 +- src/logic/Spaces.swift | 1 - src/logic/Window.swift | 68 ++++-- src/logic/Windows.swift | 2 +- src/logic/events/AccessibilityEvents.swift | 237 ++++++++++++++------- src/logic/events/KeyboardEvents.swift | 33 ++- src/ui/App.swift | 4 +- 13 files changed, 416 insertions(+), 251 deletions(-) create mode 100644 src/logic/BackgroundWork.swift delete mode 100644 src/logic/DispatchQueues.swift diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index a008454fe..cf4a95161 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -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 */; }; @@ -216,7 +216,7 @@ D04BAB51808B6118EB00DFC7 /* mstile-150x150.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "mstile-150x150.png"; sourceTree = ""; }; D04BAB6652494D7575057E86 /* 14 windows - 3 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "14 windows - 3 lines.jpg"; sourceTree = ""; }; D04BAB703998DAD0EC9A6F4A /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = ""; }; - D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueues.swift; sourceTree = ""; }; + D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundWork.swift; sourceTree = ""; }; D04BAB7714DEDEA0A53AC3ED /* main.scss */ = {isa = PBXFileReference; lastKnownFileType = file.scss; path = main.scss; sourceTree = ""; }; D04BAB7AC7316FA7117B071E /* pt-BR */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = Localizable.strings; sourceTree = ""; }; D04BAB8A94DA69A6B5008AE5 /* Pipfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Pipfile; sourceTree = ""; }; @@ -319,7 +319,7 @@ D04BA015A45DE7AFDC9794FE /* Window.swift */, D04BA10777505D8A67ABD186 /* Application.swift */, D04BA282BB16C1554595A968 /* Applications.swift */, - D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */, + D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */, D04BACABD048E62EBE4576CC /* DebugProfile.swift */, D04BAC8857A527C2E15D6598 /* events */, ); @@ -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 */, diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index 063dbbc80..2188dcbee 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -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(_ 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(_ 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(_ 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(_ 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(_ 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 +} diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index 1e53caf95..2b758f859 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -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) { @@ -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() } } diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 3521cadea..b92780700 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -35,8 +35,8 @@ class Application: NSObject { ] } - func removeObserver() { - runningApplication.safeRemoveObserver(self, "isFinishedLaunching") + deinit { + debugPrint("Deinit app", runningApplication.bundleIdentifier ?? "nil") } func addAndObserveWindows() { @@ -49,18 +49,38 @@ class Application: NSObject { } func observeNewWindows() { - if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited, - let windows = (axUiElement!.windows()?.filter { $0.isActualWindow(runningApplication.bundleIdentifier) }) { - // bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login) - let actualWindows = Array(Set(windows.filter { Windows.list.firstIndexThatMatches($0) == nil })) - if actualWindows.count > 0 { - addWindows(actualWindows) - } + if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited { + retryUntilTimeout({ [weak self] in + guard let self = self else { return } + if let windows_ = try self.axUiElement!.windows(), windows_.count > 0 { + // bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login) + let windows = try Array(Set(windows_)).map { + ( + $0, + try $0.isActualWindow(self.runningApplication.bundleIdentifier), + try $0.cgWindowId(), + try $0.title(), + try $0.isFullscreen(), + try $0.isMinimized(), + try $0.position() + ) + } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.addWindows(windows) + } + } + }) } } - private func addWindows(_ axWindows: [AXUIElement]) { - let windows = axWindows.map { Window($0, self) } + private func addWindows(_ axWindows: [(AXUIElement, Bool, CGWindowID?, String?, Bool, Bool, CGPoint?)]) { + let windows: [Window] = axWindows.compactMap { (axUiElement, isActualWindow, wid, axTitle, isFullscreen, isMinimized, position) in + if let wid = wid, isActualWindow && Windows.list.firstIndexThatMatches(axUiElement, wid) == nil { + return Window(axUiElement, self, wid, axTitle, isFullscreen, isMinimized, position) + } + return nil + } Windows.list.insertAndScaleRecycledPool(windows, at: 0) if App.app.appIsBeingUsed { Windows.cycleFocusedWindowIndex(windows.count) @@ -70,18 +90,20 @@ class Application: NSObject { private func observeEvents() { guard let axObserver = axObserver else { return } - let selfPointer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) for notification in Application.notifications(runningApplication) { - axUiElement!.subscribeWithRetry(axObserver, notification, selfPointer, { [weak self] in - // some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete - // we consider them ready when the first subscription succeeds, and list their windows again at that point + retryUntilTimeout({ [weak self] in guard let self = self else { return } - if !self.isReallyFinishedLaunching { - self.isReallyFinishedLaunching = true - self.observeNewWindows() - } - }, runningApplication) + try self.axUiElement!.subscribeToNotification(axObserver, notification, { [weak self] in + guard let self = self else { return } + // some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete + // we consider them ready when the first subscription succeeds, and list their windows again at that point + if !self.isReallyFinishedLaunching { + self.isReallyFinishedLaunching = true + self.observeNewWindows() + } + }, self.runningApplication) + }) } - CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode) + CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .defaultMode) } } diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift new file mode 100644 index 000000000..5cfb45738 --- /dev/null +++ b/src/logic/BackgroundWork.swift @@ -0,0 +1,53 @@ +import Foundation + +// queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events +class BackgroundWork { + static let uiDisplayQueue = DispatchQueue.globalConcurrent("mainQueueConcurrentWork", .userInteractive) + static let accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommands", .userInteractive) + static let accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEvents") + static let keyboardEventsThread = BackgroundThreadWithRunLoop("keyboardEvents") + + // we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue + static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount) + + // swift static variables are lazy; we artificially force the threads to init + static func start() { + _ = accessibilityEventsThread + _ = keyboardEventsThread + } +} + +extension DispatchQueue { + static func globalConcurrent(_ label: String, _ qos: DispatchQoS) -> DispatchQueue { + return DispatchQueue(label: label, target: .global(qos: qos.qosClass)) + } + + func asyncWithCap(_ deadline: DispatchTime? = nil, _ fn: @escaping () -> Void) { + let block = { + fn() + BackgroundWork.globalSemaphore.signal() + } + BackgroundWork.globalSemaphore.wait() + if let deadline = deadline { + asyncAfter(deadline: deadline, execute: block) + } else { + async(execute: block) + } + } +} + +class BackgroundThreadWithRunLoop { + var thread: Thread? + var runLoop: CFRunLoop? + + init(_ name: String) { + thread = Thread { + self.runLoop = CFRunLoopGetCurrent() + while !self.thread!.isCancelled { + CFRunLoopRun() + } + } + thread!.name = name + thread!.start() + } +} diff --git a/src/logic/DispatchQueues.swift b/src/logic/DispatchQueues.swift deleted file mode 100644 index 3c27612d4..000000000 --- a/src/logic/DispatchQueues.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -// OS events should be observed on background threads -class DispatchQueues { - static let accessibilityCommands = DispatchQueue(label: "accessibilityCommands", qos: .userInteractive, attributes: .concurrent) - static let keyboardEvents = DispatchQueue(label: "keyboardEvents", qos: .userInteractive) -} diff --git a/src/logic/Screen.swift b/src/logic/Screen.swift index f11d673fa..47c5645b8 100644 --- a/src/logic/Screen.swift +++ b/src/logic/Screen.swift @@ -23,7 +23,8 @@ class Screen { static func uuid(_ screen: NSScreen) -> ScreenUuid? { let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! UInt32 - let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber).takeRetainedValue() + // this api from apple lies in its signature; the return value can be nil; we cast to remove the warning + let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber).takeRetainedValue() as CFUUID? if screenUuid != nil, let uuid = CFUUIDCreateString(nil, screenUuid) { return uuid diff --git a/src/logic/Spaces.swift b/src/logic/Spaces.swift index 8fb94765a..0dcb3c34b 100644 --- a/src/logic/Spaces.swift +++ b/src/logic/Spaces.swift @@ -22,7 +22,6 @@ class Spaces { if let mainScreen = NSScreen.main, let uuid = Screen.uuid(mainScreen) { currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, uuid) - debugPrint("currentSpaceId", currentSpaceId) } } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index fe6e65b64..0c28f7fbb 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -2,7 +2,7 @@ import Cocoa class Window { var cgWindowId: CGWindowID - var title: String = "" + var title: String! var thumbnail: NSImage? var icon: NSImage? var shouldShowTheUser = true @@ -11,6 +11,7 @@ class Window { var isFullscreen: Bool var isMinimized: Bool var isOnAllSpaces: Bool + var position: CGPoint? var spaceId: CGSSpaceID var spaceIndex: SpaceIndex var axUiElement: AXUIElement @@ -24,33 +25,42 @@ class Window { kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification, kAXWindowResizedNotification, + kAXWindowMovedNotification, ] - init(_ axUiElement: AXUIElement, _ application: Application) { + init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ axTitle: String?, _ isFullscreen: Bool, _ isMinimized: Bool, _ position: CGPoint?) { // TODO: make a efficient batched AXUIElementCopyMultipleAttributeValues call once for each window, and store the values self.axUiElement = axUiElement self.application = application - self.cgWindowId = axUiElement.cgWindowId() + self.cgWindowId = wid self.spaceId = Spaces.currentSpaceId self.spaceIndex = Spaces.currentSpaceIndex self.icon = application.runningApplication.icon self.isHidden = application.runningApplication.isHidden - self.isFullscreen = axUiElement.isFullScreen() - self.isMinimized = axUiElement.isMinimized() + self.isFullscreen = isFullscreen + self.isMinimized = isMinimized self.isOnAllSpaces = false - self.title = bestEffortTitle() - self.isTabbed = getIsTabbed() - debugPrint("Adding window", cgWindowId, title, application.runningApplication.bundleIdentifier ?? "nil", Spaces.currentSpaceId, Spaces.currentSpaceIndex) + self.position = position + self.title = bestEffortTitle(axTitle) + self.isTabbed = false + debugPrint("Adding window", cgWindowId, title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil") observeEvents() } + deinit { + debugPrint("Deinit window", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil") + } + private func observeEvents() { AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver) guard let axObserver = axObserver else { return } for notification in Window.notifications { - axUiElement.subscribeWithRetry(axObserver, notification, nil, nil, nil, cgWindowId) + retryUntilTimeout({ [weak self] in + guard let self = self else { return } + try self.axUiElement.subscribeToNotification(axObserver, notification, nil, nil, self.cgWindowId) + }) } - CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode) + CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .defaultMode) } func refreshThumbnail() { @@ -58,33 +68,49 @@ class Window { thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) } - func getIsTabbed() -> Bool { + func getIsTabbed(_ currentWindows: [AXUIElement]?) -> Bool { // we can only detect tabs for windows on the current space, as AXUIElement.windows() only reports current space windows // also, windows that start in fullscreen will have the wrong spaceID at that point in time, so we check if they are fullscreen too return spaceId == Spaces.currentSpaceId && !isFullscreen && - application.axUiElement!.windows()?.first { $0 == axUiElement } == nil + currentWindows?.first { $0 == axUiElement } == nil } func close() { - DispatchQueues.accessibilityCommands.async { [weak self] in - self?.axUiElement.closeWindow() + BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in + guard let self = self else { return } + if self.isFullscreen { + self.axUiElement.setAttribute(kAXFullscreenAttribute, false) + } + if let closeButton_ = try? self.axUiElement.closeButton() { + closeButton_.performAction(kAXPressAction) + } } } func minDemin() { - DispatchQueues.accessibilityCommands.async { [weak self] in - self?.axUiElement.minDeminWindow() + BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in + guard let self = self else { return } + if self.isFullscreen { + self.axUiElement.setAttribute(kAXFullscreenAttribute, false) + // minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over + BackgroundWork.accessibilityCommandsQueue.asyncWithCap(.now() + .milliseconds(1000)) { [weak self] in + guard let self = self else { return } + self.axUiElement.setAttribute(kAXMinimizedAttribute, true) + } + } else { + self.axUiElement.setAttribute(kAXMinimizedAttribute, !self.isMinimized) + } } } func quitApp() { - DispatchQueues.accessibilityCommands.async { [weak self] in + BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in self?.application.runningApplication.terminate() } } func hideShowApp() { - DispatchQueues.accessibilityCommands.async { [weak self] in + BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in guard let self = self else { return } if self.application.runningApplication.isHidden { self.application.runningApplication.unhide() @@ -98,7 +124,7 @@ class Window { // macOS bug: when switching to a System Preferences window in another space, it switches to that space, // but quickly switches back to another window in that space // You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug - DispatchQueues.accessibilityCommands.async { [weak self] in + BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in guard let self = self else { return } var elementConnection = UInt32(0) CGSGetWindowOwner(cgsMainConnectionId, self.cgWindowId, &elementConnection) @@ -130,8 +156,8 @@ class Window { } // for some windows (e.g. Slack), the AX API doesn't return a title; we try CG API; finally we resort to the app name - func bestEffortTitle() -> String { - if let axTitle = axUiElement.title(), !axTitle.isEmpty { + func bestEffortTitle(_ axTitle: String?) -> String { + if let axTitle = axTitle, !axTitle.isEmpty { return axTitle } if let cgTitle = cgWindowId.title(), !cgTitle.isEmpty { diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index e12bb23a7..b692da3d9 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -107,7 +107,7 @@ class Windows { } static func isOnScreen(_ window: Window, _ screen: NSScreen) -> Bool { - if let position = window.axUiElement.position() { + if let position = window.position { var screenFrameInQuartzCoordinates = screen.frame screenFrameInQuartzCoordinates.origin.y = NSMaxY(NSScreen.screens[0].frame) - NSMaxY(screen.frame) return screenFrameInQuartzCoordinates.contains(position) diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 7c72ce7c6..2a772d839 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -1,101 +1,192 @@ import Cocoa -func axObserverCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, applicationPointer: UnsafeMutableRawPointer?) -> Void { +func axObserverCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, _: UnsafeMutableRawPointer?) -> Void { let type = notificationName as String - debugPrint("Accessibility event", type, element.title() ?? "nil") - switch type { - case kAXApplicationActivatedNotification: applicationActivated(element) - case kAXApplicationHiddenNotification, - kAXApplicationShownNotification: applicationHiddenOrShown(element, type) - case kAXWindowCreatedNotification: windowCreated(element, applicationPointer) - case kAXMainWindowChangedNotification: focusedWindowChanged(element, applicationPointer) - case kAXUIElementDestroyedNotification: windowDestroyed(element) - case kAXWindowMiniaturizedNotification, - kAXWindowDeminiaturizedNotification: windowMiniaturizedOrDeminiaturized(element, type) - case kAXTitleChangedNotification: windowTitleChanged(element) - case kAXWindowResizedNotification: windowResized(element) - case kAXFocusedUIElementChangedNotification: focusedUiElementChanged(element, applicationPointer) - default: return + retryUntilTimeout({ try handleEvent(type, element) }) +} + +func retryUntilTimeout(_ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) { + DispatchQueue.global(qos: .userInteractive).async { + do { + try fn() + } catch { + let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000 + if timePassedInSeconds < Double(AXUIElement.globalTimeoutInSeconds) { + DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + .milliseconds(10)) { + retryUntilTimeout(fn, startTime) + } + } + } } } -private func focusedUiElementChanged(_ element: AXUIElement, _ applicationPointer: UnsafeMutableRawPointer?) { - let application = Unmanaged.fromOpaque(applicationPointer!).takeUnretainedValue() - Windows.list.forEach { - if $0.application == application { - // this event is the only opportunity we have to check if a window became a tab, or a tab became a window - $0.isTabbed = $0.getIsTabbed() +func handleEvent(_ type: String, _ element: AXUIElement) throws { + debugPrint("Accessibility event", type, element) + // events are handled concurrently, thus we check that the app is still running + if let pid = try element.pid(), + let app = NSRunningApplication(processIdentifier: pid) { + switch type { + case kAXApplicationActivatedNotification: try applicationActivated(element) + case kAXApplicationHiddenNotification, + kAXApplicationShownNotification: try applicationHiddenOrShown(element, type) + case kAXWindowCreatedNotification: try windowCreated(element, app) + case kAXMainWindowChangedNotification: try focusedWindowChanged(element, app) + case kAXUIElementDestroyedNotification: try windowDestroyed(element) + case kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification: try windowMiniaturizedOrDeminiaturized(element, type) + case kAXTitleChangedNotification: try windowTitleChanged(element) + case kAXWindowResizedNotification: try windowResized(element) + case kAXWindowMovedNotification: try windowMoved(element) + case kAXFocusedUIElementChangedNotification: try focusedUiElementChanged(element, app) + default: return } } } -private func applicationActivated(_ element: AXUIElement) { - guard let appFocusedWindow = element.focusedWindow(), - let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow) else { return } - Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) - App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]]) +private func focusedUiElementChanged(_ element: AXUIElement, _ app: NSRunningApplication) throws { + if let currentWindows = try element.parent()?.windows() { + DispatchQueue.main.async { + Windows.list.forEach { + if $0.application.runningApplication.bundleIdentifier == app.bundleIdentifier { + // this event is the only opportunity we have to check if a window became a tab, or a tab became a window + $0.isTabbed = $0.getIsTabbed(currentWindows) + } + } + } + } } -private func applicationHiddenOrShown(_ element: AXUIElement, _ type: String) { - let windows = Windows.list.filter { - // for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug - $0.application.axUiElement!.pid() == element.pid() +private func applicationActivated(_ element: AXUIElement) throws { + if let appFocusedWindow = try element.focusedWindow(), + let wid = try appFocusedWindow.cgWindowId() { + DispatchQueue.main.async { + guard let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow, wid) else { return } + Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) + App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]]) + } } - windows.forEach { $0.isHidden = type == kAXApplicationHiddenNotification } - App.app.refreshOpenUi(windows) } -private func windowCreated(_ element: AXUIElement, _ applicationPointer: UnsafeMutableRawPointer?) { - let application = Unmanaged.fromOpaque(applicationPointer!).takeUnretainedValue() - guard element.isActualWindow(application.runningApplication.bundleIdentifier) else { return } - // a window being un-minimized can trigger kAXWindowCreatedNotification - guard Windows.list.firstIndexThatMatches(element) == nil else { return } - let window = Window(element, application) - Windows.list.insertAndScaleRecycledPool([window], at: 0) - Windows.cycleFocusedWindowIndex(1) - App.app.refreshOpenUi([window]) +private func applicationHiddenOrShown(_ element: AXUIElement, _ type: String) throws { + if let pid = try element.pid() { + DispatchQueue.main.async { + let windows = Windows.list.filter { + // for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug + $0.application.runningApplication.processIdentifier == pid + } + windows.forEach { $0.isHidden = type == kAXApplicationHiddenNotification } + App.app.refreshOpenUi(windows) + } + } } -private func focusedWindowChanged(_ element: AXUIElement, _ applicationPointer: UnsafeMutableRawPointer?) { - if let existingIndex = Windows.list.firstIndexThatMatches(element) { - Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) - App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]]) - } else { - let application = Unmanaged.fromOpaque(applicationPointer!).takeUnretainedValue() - if element.isActualWindow(application.runningApplication.bundleIdentifier) { - Windows.list.insert(Window(element, application), at: 0) - App.app.refreshOpenUi([Windows.list[0]]) +private func windowCreated(_ element: AXUIElement, _ app: NSRunningApplication) throws { + if let wid = try element.cgWindowId(), + try element.isActualWindow(app.bundleIdentifier) { + let axTitle = try element.title() + let isFullscreen = try element.isFullscreen() + let isMinimized = try element.isMinimized() + let position = try element.position() + DispatchQueue.main.async { + // a window being un-minimized can trigger kAXWindowCreatedNotification + if Windows.list.firstIndexThatMatches(element, wid) == nil, + let app = (Applications.list.first { $0.runningApplication.bundleIdentifier == app.bundleIdentifier }) { + let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position) + Windows.list.insertAndScaleRecycledPool([window], at: 0) + Windows.cycleFocusedWindowIndex(1) + App.app.refreshOpenUi([window]) + } } } } -private func windowDestroyed(_ element: AXUIElement) { - guard let existingIndex = Windows.list.firstIndexThatMatches(element) else { return } - Windows.list.remove(at: existingIndex) - guard Windows.list.count > 0 else { App.app.hideUi(); return } - Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex) - App.app.refreshOpenUi() +private func focusedWindowChanged(_ element: AXUIElement, _ app: NSRunningApplication) throws { + if let wid = try element.cgWindowId() { + let isActualWindow = try element.isActualWindow(app.bundleIdentifier) + let axTitle = try element.title() + let isFullscreen = try element.isFullscreen() + let isMinimized = try element.isMinimized() + let position = try element.position() + DispatchQueue.main.async { + if let existingIndex = Windows.list.firstIndexThatMatches(element, wid) { + Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) + App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]]) + } else if isActualWindow, + let app = (Applications.list.first { $0.runningApplication.bundleIdentifier == app.bundleIdentifier }) { + Windows.list.insert(Window(element, app, wid, axTitle, isFullscreen, isMinimized, position), at: 0) + App.app.refreshOpenUi([Windows.list[0]]) + } + } + } } -private func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) { - guard let index = Windows.list.firstIndexThatMatches(element) else { return } - let window = Windows.list[index] - window.isMinimized = type == kAXWindowMiniaturizedNotification - App.app.refreshOpenUi([window]) +private func windowDestroyed(_ element: AXUIElement) throws { + let wid = try element.cgWindowId() + DispatchQueue.main.async { + guard let existingIndex = Windows.list.firstIndexThatMatches(element, wid) else { return } + Windows.list.remove(at: existingIndex) + guard Windows.list.count > 0 else { App.app.hideUi(); return } + Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex) + App.app.refreshOpenUi() + } } -private func windowTitleChanged(_ element: AXUIElement) { - guard let index = Windows.list.firstIndexThatMatches(element) else { return } - let window = Windows.list[index] - guard let newTitle = window.axUiElement.title(), - newTitle != window.title else { return } - window.title = newTitle - App.app.refreshOpenUi([window]) +private func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) throws { + if let wid = try element.cgWindowId() { + DispatchQueue.main.async { + guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return } + let window = Windows.list[index] + window.isMinimized = type == kAXWindowMiniaturizedNotification + App.app.refreshOpenUi([window]) + } + } } -private func windowResized(_ element: AXUIElement) { - guard let index = Windows.list.firstIndexThatMatches(element) else { return } - let window = Windows.list[index] - window.isFullscreen = window.axUiElement.isFullScreen() - App.app.refreshOpenUi([window]) +private func windowTitleChanged(_ element: AXUIElement) throws { + if let wid = try element.cgWindowId() { + let newTitle = try element.title() + DispatchQueue.main.async { + guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return } + let window = Windows.list[index] + guard newTitle != nil && newTitle != window.title else { return } + window.title = newTitle! + App.app.refreshOpenUi([window]) + } + } +} + +private func windowResized(_ element: AXUIElement) throws { + // TODO: only trigger this at the end of the resize, not on every tick + // currenly resizing a window will lag AltTab as it triggers too much UI work + if let wid = try element.cgWindowId() { + let isFullscreen = try element.isFullscreen() + DispatchQueue.main.async { + guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return } + let window = Windows.list[index] + window.isFullscreen = isFullscreen + App.app.refreshOpenUi([window]) + } + } +} + +private func windowMoved(_ element: AXUIElement) throws { + if let wid = try element.cgWindowId() { + let position = try element.position() + DispatchQueue.main.async { + guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return } + let window = Windows.list[index] + window.position = position + App.app.refreshOpenUi([window]) + } + } +} + +class AppData { + let bundleId: String? + let axUiElement: AXUIElement + + init(_ bundleId: String?, _ axUiElement: AXUIElement) { + self.bundleId = bundleId + self.axUiElement = axUiElement + } } \ No newline at end of file diff --git a/src/logic/events/KeyboardEvents.swift b/src/logic/events/KeyboardEvents.swift index fa830b213..8590733ce 100644 --- a/src/logic/events/KeyboardEvents.swift +++ b/src/logic/events/KeyboardEvents.swift @@ -10,30 +10,27 @@ class KeyboardEvents { private var eventTap: CFMachPort? private func observe_() { - DispatchQueues.keyboardEvents.async { - let eventMask = [CGEventType.keyDown, CGEventType.keyUp, CGEventType.flagsChanged].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) }) - // CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass - eventTap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: eventMask, - callback: keyboardHandler, - userInfo: nil) - let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - CFRunLoopRun() - } + let eventMask = [CGEventType.keyDown, CGEventType.keyUp, CGEventType.flagsChanged].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) }) + // CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass + eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: keyboardHandler, + userInfo: nil) + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + CFRunLoopAddSource(BackgroundWork.keyboardEventsThread.runLoop, runLoopSource, .commonModes) } private func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, cgEvent: CGEvent, userInfo: UnsafeMutableRawPointer?) -> Unmanaged? { - if type == .keyDown || type == .keyUp || type == .flagsChanged { + if type == .keyDown || type == .keyUp || type == .flagsChanged { if let event_ = NSEvent(cgEvent: cgEvent), // workaround: NSEvent.characters is not safe outside of the main thread; this is not documented by Apple - // see https://github.com/Kentzo/ShortcutRecorder/issues/114#issuecomment-606465340 + // see https://github.com/Kentzo/ShortcutRecorder/issues/114#issuecomment-606465340 let event = NSEvent.keyEvent(with: event_.type, location: event_.locationInWindow, modifierFlags: event_.modifierFlags, - timestamp: event_.timestamp, windowNumber: event_.windowNumber, context: nil, characters: "", - charactersIgnoringModifiers: "", isARepeat: type == .flagsChanged ? false : event_.isARepeat, keyCode: event_.keyCode) { + timestamp: event_.timestamp, windowNumber: event_.windowNumber, context: nil, characters: "", + charactersIgnoringModifiers: "", isARepeat: type == .flagsChanged ? false : event_.isARepeat, keyCode: event_.keyCode) { let appWasBeingUsed = App.app.appIsBeingUsed App.shortcutMonitor.handle(event, withTarget: nil) if appWasBeingUsed || App.app.appIsBeingUsed { diff --git a/src/ui/App.swift b/src/ui/App.swift index da6bad544..6d946a032 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -34,8 +34,10 @@ class App: NSApplication, NSApplicationDelegate { #if !DEBUG PFMoveToApplicationsFolderIfNecessary() #endif + AXUIElement.setGlobalTimeout() SystemPermissions.ensureAccessibilityCheckboxIsChecked() SystemPermissions.ensureScreenRecordingCheckboxIsChecked() + BackgroundWork.start() Preferences.migratePreferences() Preferences.registerDefaults() statusItem = Menubar.make() @@ -52,8 +54,6 @@ class App: NSApplication, NSApplicationDelegate { // pre-load some windows so they are faster on first display private func preloadWindows() { - preferencesWindowController.show() - preferencesWindowController.window!.orderOut(nil) thumbnailsPanel.orderFront(nil) thumbnailsPanel.orderOut(nil) }