Skip to content

Commit

Permalink
fix: avoid subscribing to applications without windows
Browse files Browse the repository at this point in the history
  • Loading branch information
metacodes committed Apr 12, 2022
1 parent c6f656f commit 0595767
Show file tree
Hide file tree
Showing 4 changed files with 60 additions and 94 deletions.
76 changes: 1 addition & 75 deletions src/api-wrappers/AXUIElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func retryAxCallUntilTimeout_(_ group: DispatchGroup?, _ timeoutInSeconds: Doubl
} catch {
let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000
if timePassedInSeconds < timeoutInSeconds {
BackgroundWork.axCallsQueue.asyncAfter(deadline: .now() + .milliseconds(1000)) {
BackgroundWork.axCallsQueue.asyncAfter(deadline: .now() + .milliseconds(250)) {
retryAxCallUntilTimeout_(group, timeoutInSeconds, fn, startTime)
}
}
Expand Down Expand Up @@ -247,80 +247,6 @@ extension AXUIElement {
func performAction(_ action: String) {
AXUIElementPerformAction(self, action as CFString)
}

private static let ignoredBundleIDs = Set([
"com.apple.dashboard",
"com.apple.loginwindow",
"com.apple.notificationcenterui",
"com.apple.wifi.WiFiAgent",
"com.apple.Spotlight",
"com.apple.systemuiserver",
"com.apple.dock",
"com.apple.AirPlayUIAgent",
"com.apple.dock.extra",
"com.apple.PowerChime",
"com.apple.WebKit.Networking",
"com.apple.WebKit.WebContent",
"com.apple.WebKit.GPU",
"com.apple.FollowUpUI",
"com.apple.controlcenter",
"com.apple.SoftwareUpdateNotificationManager",
"com.apple.TextInputMenuAgent",
"com.apple.TextInputSwitcher"
])

/**
* Returns a Bool indicating whether or not the application will have windows.
*
* @return true if the application will have windows and false otherwise.
*/
static func isManageable(_ runningApp: NSRunningApplication) -> Bool {
guard let bundleIdentifier = runningApp.bundleIdentifier else {
return false
}
if case .prohibited = runningApp.activationPolicy {
return false
}
if AXUIElement.ignoredBundleIDs.contains(bundleIdentifier) {
return false
}
if isAgent(runningApp) {
return false
}
return true
}

// LSBackgroundOnly (Boolean - macOS) specifies whether this app runs only in the background.
// If this key exists and is set to YES, Launch Services runs the app in the background only.
// You can use this key to create faceless background apps.
// You should also use this key if your app uses higher-level frameworks that connect to the window server, but are not intended to be visible to users.
// Background apps must be compiled as Mach-O executables. This option is not available for CFM apps.

// LSUIElement (Boolean - macOS) specifies whether the app runs as an agent app.
// If this key is set to YES, Launch Services runs the app as an agent app.
// Agent apps do not appear in the Dock or in the Force Quit window.
// Although they typically run as background apps, they can come to the foreground to present a user interface if desired.
// A click on a window belonging to an agent app brings that app forward to handle events.
/**
* Returns a Bool indicating whether or not the application is an agent.
*
* @return true if the application is an agent and false otherwise.
*/
static func isAgent(_ runningApp: NSRunningApplication) -> Bool {
guard let bundle = Bundle.init(url: runningApp.bundleURL!) else {
return false
}
guard let bundleInfoDictionary = bundle.infoDictionary else {
return false
}
if bundleInfoDictionary["LSBackgroundOnly"] != nil {
return true
}
if bundleInfoDictionary["LSUIElement"] != nil {
return true
}
return false
}
}

enum AxError: Error {
Expand Down
29 changes: 13 additions & 16 deletions src/logic/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,20 @@ class Application: NSObject {

private func observeEvents() {
guard let axObserver = axObserver else { return }
// we only need to subscribe to those apps which will have windows
if AXUIElement.isManageable(runningApplication) {
for notification in Application.notifications(runningApplication) {
retryAxCallUntilTimeout { [weak self] in
guard let self = self else { return }
try self.axUiElement!.subscribeToNotification(axObserver, notification, {
DispatchQueue.main.async { [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()
}
for notification in Application.notifications(runningApplication) {
retryAxCallUntilTimeout { [weak self] in
guard let self = self else { return }
try self.axUiElement!.subscribeToNotification(axObserver, notification, {
DispatchQueue.main.async { [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)
}
}
}, self.runningApplication)
}
}
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .defaultMode)
Expand Down
26 changes: 25 additions & 1 deletion src/logic/Applications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Applications {
addInitialRunningApplications()
addInitialRunningApplicationsWindows()
WorkspaceEvents.observeRunningApplications()
WorkspaceEvents.registerFrontAppChangeNote()
}

static func addInitialRunningApplications() {
Expand Down Expand Up @@ -104,7 +105,30 @@ class Applications {
private static func isActualApplication(_ app: NSRunningApplication) -> 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()
return (isNotXpc(app) || isAndroidEmulator(app)) && !app.processIdentifier.isZombie() && isAnWindowApplication(app)
}

private static func isAnWindowApplication(_ app: NSRunningApplication) -> Bool {
if (app.isActive) {
// 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
}
}
}

private static func isNotXpc(_ app: NSRunningApplication) -> Bool {
Expand Down
23 changes: 21 additions & 2 deletions src/logic/events/WorkspaceEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,30 @@ class WorkspaceEvents {
let diff = Array(workspaceApps.symmetricDifference(previousValueOfRunningApps))
if change.kind == .insertion {
debugPrint("OS event", "apps launched", diff.map { ($0.processIdentifier, $0.bundleIdentifier) })
Applications.addRunningApplications(diff)
} 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)
}

// 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.
// But we are only interested in apps that have windows. We think that since an app can be activated, it must have a window, and subscribing to its window event makes sense and is likely to work, even if it requires multiple retries to subscribe.
// I'm not very sure if there is an edge case, but so far testing down the line has not revealed it.
// When we receive the didActivateApplicationNotification notification, NSRunningApplication.isActive=true, even if the app is not the frontmost window anymore.
// 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
}
previousValueOfRunningApps = workspaceApps
}
}

0 comments on commit 0595767

Please sign in to comment.