From 1bed0b3440a977a152dc059abb70669565941546 Mon Sep 17 00:00:00 2001 From: metacodes Date: Mon, 18 Apr 2022 09:39:13 +0800 Subject: [PATCH] fix: solve some edge-case for missing windows --- src/api-wrappers/HelperExtensions.swift | 15 ++++++ src/logic/Application.swift | 16 +++---- src/logic/Applications.swift | 32 ++++++------- src/logic/events/AccessibilityEvents.swift | 6 +++ src/logic/events/WorkspaceEvents.swift | 53 ++++++++++++++++------ 5 files changed, 80 insertions(+), 42 deletions(-) diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index 80230cdbf..972c6a2b3 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -211,3 +211,18 @@ extension String { self = NSFileTypeForHFSTypeCode(fourCharCode).trimmingCharacters(in: CharacterSet(charactersIn: "'")) } } + +fileprivate var notificationKey: Int = 1 + +extension NSRunningApplication { + var notification: Notification.Name? { + get { + return objc_getAssociatedObject(self, ¬ificationKey) as? Notification.Name + } + set { + if let value = newValue { + objc_setAssociatedObject(self, ¬ificationKey, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + } +} diff --git a/src/logic/Application.swift b/src/logic/Application.swift index a8806b52f..85fffd3ff 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -84,8 +84,8 @@ class Application: NSObject { func observeNewWindows(_ group: DispatchGroup? = nil) { if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited { retryAxCallUntilTimeout(group, 5) { [weak self] in - guard let self = self else { return } - if let axWindows_ = try self.axUiElement!.windows(), axWindows_.count > 0 { + guard let self = self, let axWindows_ = try self.axUiElement!.windows() else { throw AxError.runtimeError } + if axWindows_.count > 0 { // bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login) let axWindows = try Array(Set(axWindows_)).compactMap { if let wid = try $0.cgWindowId() { @@ -114,14 +114,10 @@ class Application: NSObject { let window = self.addWindowslessAppsIfNeeded() App.app.refreshOpenUi(window) } - if group == nil && !self.wasLaunchedBeforeAltTab && ( - // workaround: opening an app while the active app is fullscreen; we wait out the space transition animation - CGSSpaceGetType(cgsMainConnectionId, Spaces.currentSpaceId) == .fullscreen || - // workaround: some apps launch but have no window ready instantly. It's very unlikely an app would launch with no window - // so we retry until timeout, in those rare cases (e.g. Bear.app) - // we only do this for active app, to avoid wasting CPU, with the trade-off of maybe missing some windows - self.runningApplication.isActive - ) { + // workaround: some apps launch but have no window ready instantly. It's very unlikely an app would launch with no window + // so we retry until timeout, in those rare cases (e.g. Bear.app) + // we only do this for active app, to avoid wasting CPU, with the trade-off of maybe missing some windows + if group == nil && self.runningApplication.notification == NSWorkspace.didActivateApplicationNotification { throw AxError.runtimeError } } diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index a11ba62c1..5d1cf1f77 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -42,7 +42,7 @@ class Applications { static func addRunningApplications(_ runningApps: [NSRunningApplication], _ wasLaunchedBeforeAltTab: Bool = false) { runningApps.forEach { - if isActualApplication($0) { + if isActualApplication($0, wasLaunchedBeforeAltTab) { Applications.list.append(Application($0, wasLaunchedBeforeAltTab)) } } @@ -102,32 +102,30 @@ class Applications { } } - private static func isActualApplication(_ app: NSRunningApplication) -> Bool { + private static func isActualApplication(_ app: NSRunningApplication, _ wasLaunchedBeforeAltTab: Bool = false) -> Bool { // an app can start with .activationPolicy == .prohibited, then transition to != .prohibited later // an app can be both activationPolicy == .accessory and XPC (e.g. com.apple.dock.etci) - return (isNotXpc(app) || isAndroidEmulator(app)) && !app.processIdentifier.isZombie() && isAnWindowApplication(app) + return isAnWindowApplication(app, wasLaunchedBeforeAltTab) && (isNotXpc(app) || isAndroidEmulator(app)) && !app.processIdentifier.isZombie() } - private static func isAnWindowApplication(_ app: NSRunningApplication) -> Bool { - if (app.isActive) { + private static func isAnWindowApplication(_ app: NSRunningApplication, _ wasLaunchedBeforeAltTab: Bool = false) -> Bool { + if (wasLaunchedBeforeAltTab) { + // For wasLaunchedBeforeAltTab=true, we assume that those apps are all launched, if they are programs with windows. + // Even if it has 0 windows at this point, axUiElement.windows() will not throw an exception. If they are programs without windows, then axUiElement.windows() will throw an exception. + // Here I consider there is an edge case where AltTab is starting up and this program has been loading, then it is possible that axUiElement.windows() will throw an exception. + // I'm not quite sure if this happens, but even if it does, then after restarting this application, AltTab captures its window without any problem. I think this happens rarely. + let allWindows = CGWindow.windows(.optionAll) + guard let winApp = (allWindows.first { app.processIdentifier == $0.ownerPID() && $0.isNotMenubarOrOthers() && $0.id() != nil && $0.title() != nil}) else { + return false + } + return true + } else { // Because we only add the application when we receive the didActivateApplicationNotification. // So here is actually the handling for the case wasLaunchedBeforeAltTab=false. For applications where wasLaunchedBeforeAltTab=true, the majority of isActive is false. // The reason for not using axUiElement.windows() here as a way to determine if it is a window application is that // When we receive the didActivateApplicationNotification notification, the application may still be loading and axUiElement.windows() will throw an exception // So we use isActive to determine if it is a window application, even if the application is not frontmost, isActive is still true at this time return true; - } else { - do { - // For wasLaunchedBeforeAltTab=true, we assume that those apps are all launched, if they are programs with windows. - // Even if it has 0 windows at this point, axUiElement.windows() will not throw an exception. If they are programs without windows, then axUiElement.windows() will throw an exception. - // Here I consider there is an edge case where AltTab is starting up and this program has been loading, then it is possible that axUiElement.windows() will throw an exception. - // I'm not quite sure if this happens, but even if it does, then after restarting this application, AltTab captures its window without any problem. I think this happens rarely. - let axUiElement = AXUIElementCreateApplication(app.processIdentifier) - try axUiElement.windows() - return true - } catch { - return false - } } } diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 0ca987ce1..4258f89ca 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -47,6 +47,12 @@ fileprivate func applicationActivated(_ element: AXUIElement, _ pid: pid_t) thro app.focusedWindow = window App.app.checkIfShortcutsShouldBeDisabled(window, app.runningApplication) App.app.refreshOpenUi(window != nil ? [window!] : nil) + guard let win = (Windows.list.first { app.runningApplication.processIdentifier == $0.application.runningApplication.processIdentifier && !$0.isWindowlessApp }) else { + // for edge-case: some app (e.g. Bear.app) is loading and runningApplication.isFinishedLaunching is false (sometimes) when we call observeNewWindows() at first time + // as a result we miss their windows. but we will receive kAXApplicationActivatedNotification notification and we can add it successfully + app.observeNewWindows() + return + } } } } diff --git a/src/logic/events/WorkspaceEvents.swift b/src/logic/events/WorkspaceEvents.swift index c2bfc012b..574a93700 100644 --- a/src/logic/events/WorkspaceEvents.swift +++ b/src/logic/events/WorkspaceEvents.swift @@ -10,19 +10,12 @@ class WorkspaceEvents { } static func observerCallback(_ application: NSWorkspace, _ change: NSKeyValueObservedChange) { - let workspaceApps = Set(NSWorkspace.shared.runningApplications) - let diff = Array(workspaceApps.symmetricDifference(previousValueOfRunningApps)) - if change.kind == .insertion { - debugPrint("OS event", "apps launched", diff.map { ($0.processIdentifier, $0.bundleIdentifier) }) - } else if change.kind == .removal { - debugPrint("OS event", "apps quit", diff.map { ($0.processIdentifier, $0.bundleIdentifier) }) - Applications.removeRunningApplications(diff) - previousValueOfRunningApps = workspaceApps - } } static func registerFrontAppChangeNote() { NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(receiveFrontAppChangeNote(_:)), name: NSWorkspace.didActivateApplicationNotification, object: nil) + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(receiveFrontAppLaunchNote(_:)), name: NSWorkspace.didLaunchApplicationNotification, object: nil) + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(receiveFrontAppTerminateNote(_:)), name: NSWorkspace.didTerminateApplicationNotification, object: nil) } // We add apps when we receive a didActivateApplicationNotification notification, not when we receive an apps launched, because any app will have an apps launched notification. @@ -32,12 +25,42 @@ class WorkspaceEvents { // If we go to add the application when we receive the message of apps launched, at this time NSRunningApplication.isActive may be false, and try axUiElement.windows() may also throw an exception. // For those background applications, we don't receive notifications of didActivateApplicationNotification until they have their own window. For example, those menu bar applications. @objc static func receiveFrontAppChangeNote(_ notification: Notification) { - if let application = notification.userInfo?["NSWorkspaceApplicationKey"] as? NSRunningApplication { - debugPrint("OS event", "didActivateApplicationNotification", application.bundleIdentifier) - let workspaceApps = Set(NSWorkspace.shared.runningApplications) - let diff = Array(workspaceApps.symmetricDifference(previousValueOfRunningApps)) - Applications.addRunningApplications(diff) - previousValueOfRunningApps = workspaceApps + if let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { + debugPrint("OS event", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + guard let app = (Applications.list.first { application.processIdentifier == $0.pid }) else { + debugPrint("add running application", application.bundleIdentifier, application.processIdentifier) + application.notification = notification.name + Applications.addRunningApplications([application]) + return + } + guard let win = (Windows.list.first { application.processIdentifier == $0.application.runningApplication.processIdentifier && !$0.isWindowlessApp }) else { + app.hasBeenActiveOnce = true + app.runningApplication.notification = notification.name + app.observeNewWindows() + return + } + } + } + + @objc static func receiveFrontAppLaunchNote(_ notification: Notification) { + if let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { + debugPrint("OS event", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + guard let app = (Applications.list.first { application.processIdentifier == $0.pid }) else { + debugPrint("add running application", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + application.notification = notification.name + Applications.addRunningApplications([application]) + return + } + } + } + + @objc static func receiveFrontAppTerminateNote(_ notification: Notification) { + if let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { + debugPrint("OS event", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + if let app = (Applications.list.first { application.processIdentifier == $0.pid }) { + debugPrint("remove running application", application.bundleIdentifier, application.processIdentifier) + Applications.removeRunningApplications([application]) + } } } }