diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index ebf87bc3..006f60fa 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -15,8 +15,12 @@ extension AXUIElement { private static let axCallsRetriesQueueTimeoutInSeconds = Float(6) // once an app is unresponsive, let's ignore other AX calls for it to avoid congestion private static var axCallsRetriesQueueUnresponsiveAppsMap = ConcurrentMap() - // some events like window resizing trigger in quick succession. We debounce those - private static var eventsDebounceMap = ConcurrentMap() + private static var throttleMap = ConcurrentMap() + + private struct ThrottleState { + let time: UInt64 + var tailScheduled: Bool + } static func setGlobalTimeout() { AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds) @@ -31,73 +35,70 @@ extension AXUIElement { } /// 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 - static func retryAxCallUntilTimeout(file: String = #file, function: String = #function, line: Int = #line, context: String = "", after: DispatchTime? = nil, debounceType: DebounceType? = nil, pid: pid_t? = nil, wid: CGWindowID? = nil, retriesQueue: Bool = false, startTimeInNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds, callType: AXCallType, block: @escaping () throws -> Void) { - let closure = { retryAxCallUntilTimeout_(file: file, function: function, line: line, context: context, after: after, debounceType: debounceType, pid: pid, wid: wid, retriesQueue: retriesQueue, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) } - let queue = callType == .updateAppWindows ? BackgroundWork.axCallsManualDiscoveryQueue : (retriesQueue ? BackgroundWork.axCallsRetriesQueue : BackgroundWork.axCallsFirstAttemptQueue) + static func retryAxCallUntilTimeout(file: String = #file, function: String = #function, line: Int = #line, context: String = "", after: DispatchTime? = nil, pid: pid_t? = nil, wid: CGWindowID? = nil, retriesQueue: Bool = false, startTimeInNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds, callType: AXCallType, block: @escaping () throws -> Void) { + let closure = { retryAxCallUntilTimeout_(file: file, function: function, line: line, context: context, after: after, pid: pid, wid: wid, retriesQueue: retriesQueue, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) } + let queue = (callType == .updateAppWindows ? BackgroundWork.axCallsManualDiscoveryQueue + : (retriesQueue ? BackgroundWork.axCallsRetriesQueue + : BackgroundWork.axCallsFirstAttemptQueue))! + if callType == .updateWindowFromAxEvent || callType == .updateWindowFromManualDiscovery { + guard let wid else { Logger.error { (callType, context, file, function, line) }; return } + if throttleOrProceed(key: "\(callType.rawValue)-wid-\(wid)", queue: queue, closure: closure) { return } + } + if callType == .updateApp || callType == .updateAppWindows || callType == .updateAppFocusedWindow { + guard let pid else { Logger.error { (callType, context, file, function, line) }; return } + if throttleOrProceed(key: "\(callType.rawValue)-pid-\(pid)", queue: queue, closure: closure) { return } + } if let after { - queue!.addOperationAfter(deadline: after, block: closure) - } else if let debounceType, let wid { - let debounceMapKey = "\(debounceType.rawValue)\(wid)" - let workItem = DispatchWorkItem { - closure() - eventsDebounceMap.withLock { $0[debounceMapKey] = nil } - } - eventsDebounceMap.withLock { - $0[debounceMapKey]?.cancel() - $0[debounceMapKey] = workItem - } - queue!.addOperationAfter(deadline: .now() + humanPerceptionDelay) { - workItem.perform() - } + queue.addOperationAfter(deadline: after, block: closure) } else { - queue!.addOperation(closure) + queue.addOperation(closure) } } - private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, debounceType: DebounceType?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, callType: AXCallType, block: @escaping () throws -> Void) { + private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, callType: AXCallType, block: @escaping () throws -> Void) { // attempt the AX call if (try? block()) != nil { return } - // do we already have ongoing retries for this pid? The app is likely unresponsive - // if their are common updates, we avoid congestion by only retrying the latest update - if let pid, callType == .updateWindow || callType == .updateAppWindows { - let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)" - let time = axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] } - if let time { - if startTimeInNanoseconds > time { - // new most recent call; replace and retry - axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] = startTimeInNanoseconds } - } else if startTimeInNanoseconds == time { - // most recent call; retry - } else { - // old call which has been replaced; ignore - return - } - } else { - // first call; set and retry - axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] = startTimeInNanoseconds } - } - } - // should we give up? + // give up? let timePassedInSeconds = Float(DispatchTime.now().uptimeNanoseconds - startTimeInNanoseconds) / 1_000_000_000 if timePassedInSeconds >= axCallsRetriesQueueTimeoutInSeconds { Logger.info { "AX call failed for more than \(Int(axCallsRetriesQueueTimeoutInSeconds))s. Giving up on it. \(logFromContext(file, function, line, context, callType))" } - if let pid, callType == .updateWindow || callType == .updateAppWindows { - let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)" - axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0.removeValue(forKey: unresponsiveAppsMapKey) } - } return } // retry Logger.debug { "(pid:\(pid) wid:\(wid)) \(logFromContext(file, function, line, context, callType))" } - retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, debounceType: debounceType, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) + retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) } private static func logFromContext(_ file: String, _ function: String, _ line: Int, _ context: String, _ callType: AXCallType) -> String { return "Context: \((file as NSString).lastPathComponent):\(line) \(function) \(String(describing: callType)) \(context)" } + private static func throttleOrProceed(key: String, queue: LabeledOperationQueue, closure: @escaping () -> Void) -> Bool { + let windowNs: UInt64 = 1_000_000_000 + return throttleMap.withLock { map in + let now = DispatchTime.now().uptimeNanoseconds + if let state = map[key] { + let elapsed = now >= state.time ? (now - state.time) : windowNs // treat “time in future” as throttled + if elapsed < windowNs { + if !state.tailScheduled { + map[key] = ThrottleState(time: state.time, tailScheduled: true) + + let remaining = windowNs - elapsed + queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) { + throttleMap.withLock { $0[key] = nil } + closure() + } + } + return true + } + } + map[key] = ThrottleState(time: now, tailScheduled: false) + return false + } + } + @discardableResult func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool { let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil) @@ -116,7 +117,10 @@ extension AXUIElement { case subscribeToAppNotification case subscribeToWindowNotification case subscribeToDockNotification - case updateWindow + case updateWindowFromAxEvent + case updateWindowFromManualDiscovery + case updateApp + case updateAppFocusedWindow case updateAppWindows case updateDockBadges case axEventEntrypoint @@ -316,8 +320,3 @@ struct AXAttributes { var position: CGPoint? var size: CGSize? } - -enum DebounceType: Int { - case windowResizedOrMoved - case windowTitleChanged -} diff --git a/src/logic/Application.swift b/src/logic/Application.swift index e301579d..72163aa9 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -167,23 +167,29 @@ class Application: NSObject { return } for axWindow in axWindows { - let wid = try axWindow.cgWindowId() - guard wid != 0 && wid != App.app.thumbnailsPanel.windowNumber else { continue } // some bogus "windows" have wid 0 - let level = wid.level() - let a = try axWindow.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute]) - DispatchQueue.main.async { [weak self] in - guard let self else { return } - let findOrCreate = Windows.findOrCreate(axWindow, wid, self, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized) - guard let window = findOrCreate.0 else { return } - if findOrCreate.1 { - Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } - App.app.refreshOpenUiAfterExternalEvent([window]) - } + guard let wid = try? axWindow.cgWindowId() else { continue } + AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, wid: wid, callType: .updateWindowFromManualDiscovery) { [weak self] in + try self?.manuallyUpdateWindow(axWindow, wid) } } } } + func manuallyUpdateWindow(_ axWindow: AXUIElement, _ wid: CGWindowID) throws { + guard wid != 0 && wid != App.app.thumbnailsPanel.windowNumber else { return } // some bogus "windows" have wid 0 + let level = wid.level() + let a = try axWindow.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute]) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let findOrCreate = Windows.findOrCreate(axWindow, wid, self, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized) + guard let window = findOrCreate.0 else { return } + if findOrCreate.1 { + Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } + App.app.refreshOpenUiAfterExternalEvent([window]) + } + } + } + @discardableResult func addWindowlessWindowIfNeeded() -> Window? { guard runningApplication.activationPolicy == .regular && !runningApplication.isTerminated diff --git a/src/logic/Window.swift b/src/logic/Window.swift index b16ff38f..44392b46 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -87,12 +87,12 @@ class Window { private func observeEvents() { AXObserverCreate(application.pid, AccessibilityEvents.axObserverCallback, &axObserver) guard let axObserver else { return } - AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .subscribeToWindowNotification) { [weak self] in + AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in guard let self else { return } if try self.axUiElement!.subscribeToNotification(axObserver, Window.notifications.first!) { Logger.debug { "Subscribed to window: \(self.debugId)" } for notification in Window.notifications.dropFirst() { - AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.application.pid, callType: .subscribeToWindowNotification) { [weak self] in + AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.application.pid, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in try self?.axUiElement!.subscribeToNotification(axObserver, notification) } } @@ -315,7 +315,7 @@ class Window { /// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow /// workaround: we check and possibly do it here private func checkIfFocused() { - AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .updateWindow) { [weak self] in + AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, wid: cgWindowId, callType: .updateAppFocusedWindow) { [weak self] in guard let self, let focusedWindow = try self.application.axUiElement?.attributes([kAXFocusedWindowAttribute]).focusedWindow, let window = try (Windows.list.first { $0.isEqualRobust(focusedWindow, (try focusedWindow.cgWindowId())) }) else { return } diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index f8958697..6db9094c 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -10,7 +10,7 @@ class Windows { static func updateIsFullscreenOnCurrentSpace() { let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp } for window in windowsOnCurrentSpace { - AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, callType: .updateWindow) { [weak window] in + AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, wid: window.cgWindowId, callType: .updateWindowFromAxEvent) { [weak window] in guard let window else { return } // we reuse existing code, to update .isFullscreen, as if there was a kAXWindowResizedNotification try AccessibilityEvents.handleEventWindow(kAXWindowResizedNotification, window.cgWindowId!, window.application.pid, window.axUiElement!) diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index edc4bb0f..c3e0576d 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -15,34 +15,40 @@ class AccessibilityEvents { let pid = try element.pid() Logger.debug { "\(type) pid:\(pid)" } if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) { - try handleEventApp(type, pid, element) + AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, callType: .updateApp) { + try handleEventApp(type, pid, element) + } } else { - try handleEventWindowWithDebounceIfNecessary(type, pid, element) + let wid = try element.cgWindowId() + AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) { + try handleEventWindow(type, wid, pid, element) + } } } private static func handleEventApp(_ type: String, _ pid: pid_t, _ element: AXUIElement) throws { let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow + let wid = try appFocusedWindow?.cgWindowId() DispatchQueue.main.async { guard let app = Applications.findOrCreate(pid) else { return } Logger.info { "\(type) app:\(app.debugId)" } if type == kAXApplicationActivatedNotification { - applicationActivated(app, pid, type, appFocusedWindow) + applicationActivated(app, pid, type, appFocusedWindow, wid) } else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification { applicationHiddenOrShown(app, pid, type) } } } - private static func applicationActivated(_ app: Application, _ pid: pid_t, _ type: String, _ appFocusedWindow: AXUIElement?) { + private static func applicationActivated(_ app: Application, _ pid: pid_t, _ type: String, _ appFocusedWindow: AXUIElement?, _ wid: CGWindowID?) { Applications.frontmostPid = pid if app.hasBeenActiveOnce != true { app.hasBeenActiveOnce = true } - if let appFocusedWindow { + if let appFocusedWindow, let wid { // if there is a focusedWindow, we reuse existing code to process it as if it was a kAXFocusedWindowChangedNotification - AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) { - try handleEventWindowWithDebounceIfNecessary(kAXFocusedWindowChangedNotification, pid, appFocusedWindow) + AXUIElement.retryAxCallUntilTimeout(context: "\(type) \(app.debugId))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) { + try handleEventWindow(kAXFocusedWindowChangedNotification, wid, pid, appFocusedWindow) } } else { App.app.checkIfShortcutsShouldBeDisabled(nil, app) @@ -67,20 +73,9 @@ class AccessibilityEvents { } } - private static func handleEventWindowWithDebounceIfNecessary(_ type: String, _ pid: pid_t, _ element: AXUIElement) throws { - let wid = try element.cgWindowId() - guard wid != 0 || type == kAXUIElementDestroyedNotification else { return } // some bogus "windows" have wid 0 - if type == kAXTitleChangedNotification || type == kAXWindowResizedNotification || type == kAXWindowMovedNotification { - AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", debounceType: (type == kAXTitleChangedNotification ? .windowTitleChanged : .windowResizedOrMoved), pid: pid, wid: wid, callType: .updateWindow) { - try handleEventWindow(type, wid, pid, element) - } - } else { - try handleEventWindow(type, wid, pid, element) - } - } - static func handleEventWindow(_ type: String, _ wid: CGWindowID, _ pid: pid_t, _ element: AXUIElement) throws { - guard wid != App.app.thumbnailsPanel.windowNumber else { return } // don't process events for the thumbnails panel + guard wid != 0 || type == kAXUIElementDestroyedNotification, + wid != App.app.thumbnailsPanel.windowNumber else { return } // don't process events for the thumbnails panel let level = wid.level() let a = try element.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute]) DispatchQueue.main.async {