diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index 704359a4..45734f13 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -49,14 +49,40 @@ extension AXUIElement { return attribute(kAXSubroleAttribute, String.self) } - func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ previousResult: AXError? = nil) { + func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ attemptsCount: Int = 0) { + if attemptsCount == 0 || attemptsCount % 1000 == 0 { + if let runningApplication = runningApplication { +// debugPrint("attempt pid", attemptsCount, pid, notification, Applications.appsInSubscriptionRetryLoop.filter { $0.starts(with: String(pid)) }) + } + if let wid = wid { +// debugPrint("attempt wid", attemptsCount, wid, notification, Windows.windowsInSubscriptionRetryLoop.filter { $0.starts(with: String(wid)) }) + } + } + if let runningApplication = runningApplication, Applications.appsInSubscriptionRetryLoop.first(where: { $0 == String(runningApplication.processIdentifier) + String(notification) }) == nil { +// debugPrint("early quit pid", attemptsCount, pid, notification) + return + } + if let wid = wid, Windows.windowsInSubscriptionRetryLoop.first(where: { $0 == String(wid) + String(notification) }) == nil { +// debugPrint("early quit wid", attemptsCount, wid, notification) + return + } let result = AXObserverAddNotification(axObserver, self, notification as CFString, pointer) if result == .success || result == .notificationUnsupported || result == .notificationAlreadyRegistered { + debugPrint("subbed", attemptsCount, runningApplication, wid, Applications.list.first(where: { $0.runningApplication.processIdentifier == runningApplication?.processIdentifier })) callback?() + if let runningApplication = runningApplication { + Application.stopSubscriptionRetries(notification, runningApplication) + debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop) + } + if let wid = wid { + Window.stopSubscriptionRetries(notification, wid) + debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop) + } return } - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10), execute: { - self.subscribeWithRetry(axObserver, notification, pointer, callback, result) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10), execute: { [weak self] in + guard let self = self else { return } + self.subscribeWithRetry(axObserver, notification, pointer, callback, runningApplication, wid, attemptsCount + 1) }) } diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 011802fc..67b48316 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -6,6 +6,20 @@ class Application: NSObject { var axObserver: AXObserver? var isReallyFinishedLaunching = false + static let notifications = [ + kAXApplicationActivatedNotification, + kAXFocusedWindowChangedNotification, + kAXWindowCreatedNotification, + kAXApplicationHiddenNotification, + kAXApplicationShownNotification, + ] + + // some apps never finish their subscription retry loop; they should be stopped to avoid infinite loop + static func stopSubscriptionRetries(_ notification: String, _ runningApplication: NSRunningApplication) { + debugPrint("removeObservers", runningApplication.processIdentifier, runningApplication.bundleIdentifier) + Applications.appsInSubscriptionRetryLoop.removeAll { $0 == String(runningApplication.processIdentifier) + String(notification) } + } + init(_ runningApplication: NSRunningApplication) { self.runningApplication = runningApplication super.init() @@ -16,6 +30,14 @@ class Application: NSObject { } } + deinit { + debugPrint("deinit", runningApplication.processIdentifier, runningApplication.bundleIdentifier) + // some apps never finish launching; subscription retries should be stopped to avoid infinite loops + Application.notifications.forEach { Application.stopSubscriptionRetries($0, runningApplication) } + // some apps never finish launching; observer should be removed to avoid leak + removeObserver() + } + func removeObserver() { runningApplication.safeRemoveObserver(self, "isFinishedLaunching") } @@ -53,22 +75,20 @@ class Application: NSObject { private func observeEvents() { guard let axObserver = axObserver else { return } let selfPointer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - for notification in [ - kAXApplicationActivatedNotification, - kAXFocusedWindowChangedNotification, - kAXWindowCreatedNotification, - kAXApplicationHiddenNotification, - kAXApplicationShownNotification, - ] { - axUiElement!.subscribeWithRetry(axObserver, notification, selfPointer, { + for notification in Application.notifications { + debugPrint("subscribeWithRetry app", runningApplication.processIdentifier, notification, runningApplication.bundleIdentifier) + Applications.appsInSubscriptionRetryLoop.append(String(runningApplication.processIdentifier) + String(notification)) + 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 + guard let self = self else { return } if !self.isReallyFinishedLaunching { self.isReallyFinishedLaunching = true self.observeNewWindows() } - }) + }, runningApplication) } + debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop) CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode) } } diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index 1b05dcb6..64fe88b4 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -3,6 +3,7 @@ import Cocoa class Applications { static var list = [Application]() static var appsObserver = RunningApplicationsObserver() + static var appsInSubscriptionRetryLoop = [String]() static func observeNewWindows() { for app in list { @@ -53,12 +54,11 @@ class Applications { static func removeRunningApplications(_ runningApps: [NSRunningApplication]) { for runningApp in runningApps { - guard let app = Applications.list.first(where: { $0.runningApplication.isEqual(runningApp) }) else { continue } - Windows.list.removeAll(where: { $0.application.runningApplication.isEqual(runningApp) }) - // some apps never finish launching; the observer leaks for them without this - app.removeObserver() Applications.list.removeAll(where: { $0.runningApplication.isEqual(runningApp) }) + Windows.list.removeAll(where: { $0.application.runningApplication.isEqual(runningApp) }) } + debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop) + debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop) guard Windows.list.count > 0 else { (App.shared as! App).hideUi(); return } // TODO: implement of more sophisticated way to decide which thumbnail gets focused on app quit Windows.focusedWindowIndex = 1 diff --git a/src/logic/Window.swift b/src/logic/Window.swift index fc689404..5db9d764 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -14,6 +14,18 @@ class Window { var application: Application var axObserver: AXObserver? + static let notifications = [ + kAXUIElementDestroyedNotification, + kAXTitleChangedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification, + ] + + static func stopSubscriptionRetries(_ notification: String, _ cgWindowId: CGWindowID) { + debugPrint("removeObservers", cgWindowId) + Windows.windowsInSubscriptionRetryLoop.removeAll { $0 == (String(cgWindowId) + String(notification)) } + } + init(_ axUiElement: AXUIElement, _ application: Application) { // TODO: make a efficient batched AXUIElementCopyMultipleAttributeValues call once for each window, and store the values self.axUiElement = axUiElement @@ -30,17 +42,22 @@ class Window { observeEvents() } + deinit { + debugPrint("deinit", cgWindowId, title) + // some windows never finish launching; subscription retries should be stopped to avoid infinite loops + Window.notifications.forEach { Window.stopSubscriptionRetries($0, cgWindowId) } + } + private func observeEvents() { AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver) guard let axObserver = axObserver else { return } - for notification in [ - kAXUIElementDestroyedNotification, - kAXTitleChangedNotification, - kAXWindowMiniaturizedNotification, - kAXWindowDeminiaturizedNotification, - ] { - axUiElement.subscribeWithRetry(axObserver, notification, nil) + for notification in Window.notifications { + debugPrint("subscribeWithRetry win", cgWindowId, notification, title) + Windows.windowsInSubscriptionRetryLoop.append(String(cgWindowId) + String(notification)) + axUiElement.subscribeWithRetry(axObserver, notification, nil, nil, nil, cgWindowId) } + debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop) + debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop) CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode) } @@ -56,7 +73,8 @@ 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.focusActions.async { + DispatchQueues.focusActions.async { [weak self] in + guard let self = self else { return } var elementConnection = UInt32(0) CGSGetWindowOwner(cgsMainConnectionId, self.cgWindowId, &elementConnection) var psn = ProcessSerialNumber() @@ -115,6 +133,7 @@ private func axObserverCallback(observer: AXObserver, element: AXUIElement, noti private func eventWindowDestroyed(_ app: App, _ element: AXUIElement) { guard let existingIndex = Windows.list.firstIndexThatMatches(element) else { return } Windows.list.remove(at: existingIndex) + debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop) guard Windows.list.count > 0 else { app.hideUi(); return } Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex) app.refreshOpenUi() diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index b6656858..530f61c7 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -4,6 +4,7 @@ class Windows { // order in the array is important: most-recently-used elements are first static var list = [Window]() static var focusedWindowIndex = Array.Index(0) + static var windowsInSubscriptionRetryLoop = [String]() static func focusedWindow() -> Window? { return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil diff --git a/src/ui/App.swift b/src/ui/App.swift index 6986922b..27962b1a 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -122,7 +122,8 @@ class App: NSApplication, NSApplicationDelegate { Windows.refreshAllThumbnails() Windows.focusedWindowIndex = 0 Windows.cycleFocusedWindowIndex(step) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay, execute: { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay, execute: { [weak self] in + guard let self = self else { return } self.refreshOpenUi() if self.uiWorkShouldBeDone { self.thumbnailsPanel?.show() } })