Skip to content

Commit

Permalink
fix: solve some edge-case for missing windows
Browse files Browse the repository at this point in the history
  • Loading branch information
metacodes committed Apr 18, 2022
1 parent 0595767 commit 1bed0b3
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 42 deletions.
15 changes: 15 additions & 0 deletions src/api-wrappers/HelperExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, &notificationKey) as? Notification.Name
}
set {
if let value = newValue {
objc_setAssociatedObject(self, &notificationKey, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
}
}
16 changes: 6 additions & 10 deletions src/logic/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}
}
Expand Down
32 changes: 15 additions & 17 deletions src/logic/Applications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/logic/events/AccessibilityEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down
53 changes: 38 additions & 15 deletions src/logic/events/WorkspaceEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,12 @@ class WorkspaceEvents {
}

static func observerCallback<A>(_ application: NSWorkspace, _ change: NSKeyValueObservedChange<A>) {
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.
Expand All @@ -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])
}
}
}
}

0 comments on commit 1bed0b3

Please sign in to comment.