diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index 33e359b4..a5ab3857 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -47,7 +47,7 @@ extension Array { let group = DispatchGroup() for element in self { BackgroundWork.globalSemaphore.wait() - BackgroundWork.uiDisplayQueue.async(group: group) { + BackgroundWork.mainQueueConcurrentWorkQueue.async(group: group) { group.enter() fn(element) BackgroundWork.globalSemaphore.signal() diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 09b8a082..57423c98 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -50,7 +50,7 @@ class Application: NSObject { func observeNewWindows() { if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited { - retryUntilTimeout({ [weak self] in + retryAxCallUntilTimeout({ [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) @@ -92,15 +92,17 @@ class Application: NSObject { private func observeEvents() { guard let axObserver = axObserver else { return } for notification in Application.notifications(runningApplication) { - retryUntilTimeout({ [weak self] in + retryAxCallUntilTimeout({ [weak self] in guard let self = self else { return } - 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() + 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) }) diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index 5cfb4573..a188dc6a 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -2,13 +2,16 @@ 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") + static let mainQueueConcurrentWorkQueue = DispatchQueue.globalConcurrent("mainQueueConcurrentWorkQueue", .userInteractive) + static let accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommandsQueue", .userInteractive) + static let axCallsQueue = DispatchQueue.globalConcurrent("axCallsQueue", .userInteractive) + static let accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread") + static let keyboardEventsThread = BackgroundThreadWithRunLoop("keyboardEventsThread") // we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount) + // AX calls may block for a long time. We use a big semaphore to avoid blocking the main thread in case the window server is busy + static let axCallsGlobalSemaphore = DispatchSemaphore(value: 100) // swift static variables are lazy; we artificially force the threads to init static func start() { @@ -22,12 +25,12 @@ extension DispatchQueue { return DispatchQueue(label: label, target: .global(qos: qos.qosClass)) } - func asyncWithCap(_ deadline: DispatchTime? = nil, _ fn: @escaping () -> Void) { + func asyncWithCap(_ deadline: DispatchTime? = nil, semaphore: DispatchSemaphore = BackgroundWork.globalSemaphore, _ fn: @escaping () -> Void) { let block = { fn() - BackgroundWork.globalSemaphore.signal() + semaphore.signal() } - BackgroundWork.globalSemaphore.wait() + semaphore.wait() if let deadline = deadline { asyncAfter(deadline: deadline, execute: block) } else { diff --git a/src/logic/Window.swift b/src/logic/Window.swift index 38d9d8e3..823e26cb 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -55,7 +55,7 @@ class Window { AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver) guard let axObserver = axObserver else { return } for notification in Window.notifications { - retryUntilTimeout({ [weak self] in + retryAxCallUntilTimeout({ [weak self] in guard let self = self else { return } try self.axUiElement.subscribeToNotification(axObserver, notification, nil, nil, self.cgWindowId) }) diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 89422903..eed47296 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -2,18 +2,19 @@ import Cocoa func axObserverCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, _: UnsafeMutableRawPointer?) -> Void { let type = notificationName as String - retryUntilTimeout({ try handleEvent(type, element) }) + retryAxCallUntilTimeout({ try handleEvent(type, element) }) } -func retryUntilTimeout(_ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) { - DispatchQueue.global(qos: .userInteractive).async { +// if the window server is busy, it may not reply to AX calls. We retry right before the call times-out and returns a bogus value +func retryAxCallUntilTimeout(_ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) { + BackgroundWork.axCallsQueue.asyncWithCap(semaphore: BackgroundWork.axCallsGlobalSemaphore) { 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) + BackgroundWork.axCallsQueue.asyncWithCap(.now() + .milliseconds(10), semaphore: BackgroundWork.axCallsGlobalSemaphore) { + retryAxCallUntilTimeout(fn, startTime) } } }