diff --git a/.gitignore b/.gitignore index d36962eb..05bc7188 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ codesign.conf codesign.crt codesign.key codesign.p12 +/.claude/worktrees/ diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 10180110..9d928988 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ BF0C89C49CFBAAB4646BB8CB /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82A8848224E76B880A83 /* InfoPlist.strings */; }; BF0C89F0632E0A2EC52D9168 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C87749F1A64E2D3305181 /* InfoPlist.strings */; }; BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */; }; + AA0C8A100000000000000001 /* AXCallScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0C8A000000000000000001 /* AXCallScheduler.swift */; }; BF0C8A3F32B2177AF407DC7E /* DockEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C870C14C20C936BA4AF40 /* DockEvents.swift */; }; BF0C8A408B8E1C662E457A99 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C86C535CC75FA07FAA09B /* InfoPlist.strings */; }; BF0C8A49891371E4027E0641 /* MacroPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */; }; @@ -528,6 +529,7 @@ BF0C89D7F23D82A3FF442E0B /* build_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = build_app.sh; sourceTree = ""; }; BF0C89F6EA16DD7322626E20 /* FUNDING.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = FUNDING.yml; sourceTree = ""; }; BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; + AA0C8A000000000000000001 /* AXCallScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXCallScheduler.swift; sourceTree = ""; }; BF0C8A261AAA42191426A587 /* run_tests.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = run_tests.sh; sourceTree = ""; }; BF0C8A3DAEA5B956593AF97A /* kn */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = kn; path = InfoPlist.strings; sourceTree = ""; }; BF0C8A479D2B7FB80531F037 /* PopupButtonLikeSystemSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PopupButtonLikeSystemSettings.swift; sourceTree = ""; }; @@ -1433,6 +1435,7 @@ BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */, BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */, BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */, + AA0C8A000000000000000001 /* AXCallScheduler.swift */, ); path = logic; sourceTree = ""; @@ -2643,6 +2646,7 @@ BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */, BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */, BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */, + AA0C8A100000000000000001 /* AXCallScheduler.swift in Sources */, BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */, BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */, BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */, diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index 6f3010cd..9df5c28a 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -6,13 +6,11 @@ import ApplicationServices.HIServices.AXRoleConstants import ApplicationServices.HIServices.AXAttributeConstants import ApplicationServices.HIServices.AXActionConstants -/// common, subscriptions, concurrency +/// common, subscriptions extension AXUIElement { // default timeout for AX calls is 6s // we reduce to 1s to avoid AX calls blocking threads, thus too many threads getting created to make the next AX calls private static let globalMessagingTimeoutInSeconds = Float(1) - // if an app times out our AX calls, we retry for 6s then give up - private static let axCallsRetriesQueueTimeoutInSeconds = Float(6) static func setGlobalTimeout() { AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds) } @@ -25,39 +23,6 @@ extension AXUIElement { // for success or other errors we don't throw } - /// 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, 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 == .updateAppWindowsFromManualDiscovery ? BackgroundWork.axCallsManualDiscoveryQueue - : (retriesQueue ? BackgroundWork.axCallsRetriesQueue - : BackgroundWork.axCallsFirstAttemptQueue))! - if let after { - queue.addOperationAfter(deadline: after, block: closure) - } else { - queue.addOperation(closure) - } - } - - 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 - } - // 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))" } - 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, 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)" - } - @discardableResult func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool { let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil) @@ -71,23 +36,6 @@ extension AXUIElement { // temporary issue; subscription may succeed if retried throw AxError.runtimeError } - - enum AXCallType: Int { - case subscribeToAppNotification - case subscribeToWindowNotification - case subscribeToDockNotification - - case updateAppWindowsFromManualDiscovery - case updateWindowFromManualDiscovery - - case entrypointFromAxEvent - case updateWindowFromAxEvent - case updateAppFromAxEvent - - case updateAppFocusedWindowFromWindowInit - case updateDockBadgesFromShowingUi - } - } /// Attributes diff --git a/src/logic/AXCallScheduler.swift b/src/logic/AXCallScheduler.swift new file mode 100644 index 00000000..09c9654a --- /dev/null +++ b/src/logic/AXCallScheduler.swift @@ -0,0 +1,254 @@ +import Foundation + +class AXCallScheduler { + static let shared = AXCallScheduler() + + let fastQueue: LabeledOperationQueue + let retryQueue: LabeledOperationQueue + + private let lock = NSLock() + private var keyStates = [String: KeyState]() + private var unresponsivePids = Set() + + private static let throttleDelayNs: UInt64 = 200_000_000 + private static let giveUpAfterSeconds: Float = 60.0 + private static let backoffStepsNs: [UInt64] = [200_000_000, 1_000_000_000, 2_000_000_000, 5_000_000_000] + + private enum Phase { + case idle + case throttled + case executing + case retrying + } + + private struct KeyState { + var phase: Phase = .idle + var lastExecutionTime: UInt64 = 0 + var retryStartTime: UInt64 = 0 + var retryCount = 0 + var pendingBlock: (() throws -> Void)? + var pendingPid: pid_t? + var pendingContext: String? + var cancelRetries = false + } + + private init() { + fastQueue = LabeledOperationQueue("axCallsFast", .userInteractive, 16) + retryQueue = LabeledOperationQueue("axCallsRetry", .userInteractive, 8) + } + + func schedule(key: String, file: String = #file, function: String = #function, line: Int = #line, context: String = "", pid: pid_t? = nil, block: @escaping () throws -> Void) { + lock.lock() + var state = keyStates[key] ?? KeyState() + switch state.phase { + case .idle: + let now = DispatchTime.now().uptimeNanoseconds + let elapsed = now >= state.lastExecutionTime ? (now - state.lastExecutionTime) : Self.throttleDelayNs + if elapsed >= Self.throttleDelayNs { + state.phase = .executing + keyStates[key] = state + lock.unlock() + submitToQueue(key: key, pid: pid, file: file, function: function, line: line, context: context, block: block) + } else { + state.phase = .throttled + state.pendingBlock = block + state.pendingPid = pid + state.pendingContext = context + keyStates[key] = state + let remaining = Self.throttleDelayNs - elapsed + lock.unlock() + let queue = queueForPid(pid) + queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [self] in + fireThrottled(key: key, file: file, function: function, line: line) + } + } + case .throttled: + state.pendingBlock = block + state.pendingPid = pid + state.pendingContext = context + keyStates[key] = state + lock.unlock() + case .executing: + state.pendingBlock = block + state.pendingPid = pid + state.pendingContext = context + keyStates[key] = state + lock.unlock() + case .retrying: + state.pendingBlock = block + state.pendingPid = pid + state.pendingContext = context + state.cancelRetries = true + keyStates[key] = state + lock.unlock() + } + } + + func submit(_ block: @escaping () -> Void) { + fastQueue.addOperation(block) + } + + func removeEntry(key: String) { + lock.lock() + keyStates[key] = nil + lock.unlock() + } + + func removeUnresponsivePid(_ pid: pid_t) { + lock.lock() + unresponsivePids.remove(pid) + lock.unlock() + } + + private func queueForPid(_ pid: pid_t?) -> LabeledOperationQueue { + if let pid, unresponsivePids.contains(pid) { + return retryQueue + } + return fastQueue + } + + private func fireThrottled(key: String, file: String, function: String, line: Int) { + lock.lock() + guard var state = keyStates[key], state.phase == .throttled, let block = state.pendingBlock else { + lock.unlock() + return + } + let pid = state.pendingPid + let context = state.pendingContext ?? "" + state.pendingBlock = nil + state.pendingPid = nil + state.pendingContext = nil + state.phase = .executing + keyStates[key] = state + lock.unlock() + attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block) + } + + private func submitToQueue(key: String, pid: pid_t?, file: String, function: String, line: Int, context: String, block: @escaping () throws -> Void) { + let queue = queueForPid(pid) + queue.addOperation { [self] in + attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block) + } + } + + private func attemptBlock(key: String, pid: pid_t?, file: String, function: String, line: Int, context: String, retryStartTime: UInt64, block: @escaping () throws -> Void) { + // check if cancelled by a newer request + lock.lock() + if let state = keyStates[key], state.cancelRetries { + lock.unlock() + drainPending(key: key, file: file, function: function, line: line) + return + } + lock.unlock() + + if (try? block()) != nil { + // success + if let pid { + lock.lock() + unresponsivePids.remove(pid) + lock.unlock() + } + onComplete(key: key, file: file, function: function, line: line) + return + } + + // failure + if let pid { + lock.lock() + unresponsivePids.insert(pid) + lock.unlock() + } + + let elapsed = Float(DispatchTime.now().uptimeNanoseconds - retryStartTime) / 1_000_000_000 + if elapsed >= Self.giveUpAfterSeconds { + Logger.info { "AX call timed out after \(Int(Self.giveUpAfterSeconds))s. \(Self.logContext(file, function, line, context))" } + if let pid { + lock.lock() + unresponsivePids.remove(pid) + lock.unlock() + } + onComplete(key: key, file: file, function: function, line: line) + return + } + + // schedule retry with backoff: 200ms, 1s, 2s, 5s, 5s, ... + let delayNs: UInt64 + lock.lock() + if var state = keyStates[key] { + state.phase = .retrying + let step = min(state.retryCount, Self.backoffStepsNs.count - 1) + delayNs = Self.backoffStepsNs[step] + state.retryCount += 1 + keyStates[key] = state + } else { + delayNs = Self.backoffStepsNs[0] + } + lock.unlock() + + Logger.debug { "Retrying AX call in \(delayNs / 1_000_000)ms. \(Self.logContext(file, function, line, context))" } + retryQueue.addOperationAfter(deadline: .now() + .nanoseconds(Int(delayNs))) { [self] in + attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: retryStartTime, block: block) + } + } + + private func onComplete(key: String, file: String, function: String, line: Int) { + lock.lock() + if var state = keyStates[key] { + state.lastExecutionTime = DispatchTime.now().uptimeNanoseconds + state.phase = .idle + state.cancelRetries = false + state.retryCount = 0 + keyStates[key] = state + } + lock.unlock() + drainPending(key: key, file: file, function: function, line: line) + } + + private func drainPending(key: String, file: String, function: String, line: Int) { + lock.lock() + guard var state = keyStates[key], let block = state.pendingBlock else { + if var state = keyStates[key] { + state.cancelRetries = false + state.phase = .idle + keyStates[key] = state + } + lock.unlock() + return + } + let pid = state.pendingPid + let context = state.pendingContext ?? "" + state.pendingBlock = nil + state.pendingPid = nil + state.pendingContext = nil + state.cancelRetries = false + + // apply throttle check before executing pending block + let now = DispatchTime.now().uptimeNanoseconds + let elapsed = now >= state.lastExecutionTime ? (now - state.lastExecutionTime) : Self.throttleDelayNs + if elapsed >= Self.throttleDelayNs { + state.phase = .executing + keyStates[key] = state + lock.unlock() + let queue = queueForPid(pid) + queue.addOperation { [self] in + attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block) + } + } else { + state.phase = .throttled + state.pendingBlock = block + state.pendingPid = pid + state.pendingContext = context + keyStates[key] = state + let remaining = Self.throttleDelayNs - elapsed + lock.unlock() + let queue = queueForPid(pid) + queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [self] in + fireThrottled(key: key, file: file, function: function, line: line) + } + } + } + + private static func logContext(_ file: String, _ function: String, _ line: Int, _ context: String) -> String { + "\((file as NSString).lastPathComponent):\(line) \(function) \(context)" + } +} diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 6c82b2b0..43c1efc2 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -128,7 +128,7 @@ class Application: NSObject { private func observeEvents() { guard let axObserver else { return } - AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .subscribeToAppNotification) { [weak self] in + AXCallScheduler.shared.schedule(key: "sub-app-\(pid)", context: debugId, pid: pid) { [weak self] in guard let self, !self.isReallyFinishedLaunching else { return } if try self.axUiElement!.subscribeToNotification(axObserver, Application.notifications.first!) { Logger.debug { "Subscribed to app: \(self.debugId)" } @@ -138,7 +138,7 @@ class Application: NSObject { // windows opened before that point won't send a notification, so check those windows manually here self.isReallyFinishedLaunching = true for notification in Application.notifications.dropFirst() { - AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.pid, callType: .subscribeToAppNotification) { [weak self] in + AXCallScheduler.shared.schedule(key: "sub-app-\(self.pid)-\(notification)", context: self.debugId, pid: self.pid) { [weak self] in try self?.axUiElement!.subscribeToNotification(axObserver, notification) } } @@ -158,29 +158,6 @@ class Application: NSObject { CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes) } - func manuallyUpdateWindow(_ axWindow: AXUIElement, _ wid: CGWindowID) throws { - guard wid != 0 && wid != TilesPanel.shared.windowNumber else { return } // some bogus "windows" have wid 0 - let level = wid.level() - // if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this - let isSelf = pid == ProcessInfo.processInfo.processIdentifier - let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute]) - let a = try axWindow.attributes(keys) - let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children) - 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 } - var tabStateChanged = false - if tabSiblingTitles != nil || window.tabbedSiblingWids != nil { - tabStateChanged = TabGroup.updateState(window, tabSiblingTitles) - } - if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) { - if findOrCreate.1 { Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } } - App.refreshOpenUiAfterExternalEvent([window]) - } - } - } - @discardableResult func addWindowlessWindowIfNeeded() -> Window? { guard runningApplication.activationPolicy == .regular && !runningApplication.isTerminated diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index b88056c4..423e81a5 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -4,8 +4,12 @@ import ApplicationServices class Applications { static var list = [Application]() static var frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier - static let manualAppUpdatesThrottler = ThrottlerWithKey(delayInMs: 1000) - static let manualWindowUpdatesThrottler = ThrottlerWithKey(delayInMs: 1000) + // Layer 0: global throttle on manuallyRefreshAllWindows (panel show full-sync) + static let manualRefreshThrottler = Throttler(delayInMs: 1000) + // Layer 1 (AX IPC throttle + retry + concurrency) is handled by AXCallScheduler.shared + // Layer 2: throttle mutations to Applications.list / Windows.list on main thread + static let appListUpdateThrottler = ThrottlerWithKey(delayInMs: 200) + static let windowListUpdateThrottler = ThrottlerWithKey(delayInMs: 200) static let badgesThrottler = Throttler(delayInMs: 1000) static func initialDiscovery() { @@ -18,8 +22,11 @@ class Applications { } static func manuallyRefreshAllWindows() { - removeZombieWindows() - addMissingWindows() + manualRefreshThrottler.throttleOrProceed { + removeZombieWindows() + addMissingWindows() + reviewExistingWindows() + } } /// we may not receive a window-created event in some cases: @@ -34,55 +41,92 @@ class Applications { } static func manuallyUpdateWindows(_ app: Application) { - manualAppUpdatesThrottler.throttleOrProceed(key: "\(app.pid)") { [weak app] in - guard let app else { return } - AXUIElement.retryAxCallUntilTimeout(context: app.debugId, pid: app.pid, callType: .updateAppWindowsFromManualDiscovery) { [weak app] in - guard let app, let axUiElement = app.axUiElement else { return } - let axWindows = try axUiElement.allWindows(app.pid) - guard !axWindows.isEmpty else { - // workaround: some apps launch but take a while to create their window(s) - // initial windows don't trigger a windowCreated notification, so we won't get notified - // it's very unlikely an app would launch with no initial window - // so we retry until timeout, in those rare cases (e.g. Bear.app) - // we only do this for regular, active app, to avoid wasting CPU, with the trade-off of maybe missing some windows - if app.runningApplication.isActive && app.runningApplication.activationPolicy == .regular { - throw AxError.runtimeError - } - return + AXCallScheduler.shared.schedule(key: "pid-\(app.pid)", context: app.debugId, pid: app.pid) { [weak app] in + guard let app, let axUiElement = app.axUiElement else { return } + let axWindows = try axUiElement.allWindows(app.pid) + guard !axWindows.isEmpty else { + // workaround: some apps launch but take a while to create their window(s) + // initial windows don't trigger a windowCreated notification, so we won't get notified + // it's very unlikely an app would launch with no initial window + // so we retry until timeout, in those rare cases (e.g. Bear.app) + // we only do this for regular, active app, to avoid wasting CPU, with the trade-off of maybe missing some windows + if app.runningApplication.isActive && app.runningApplication.activationPolicy == .regular { + throw AxError.runtimeError } - for axWindow in axWindows { - guard let wid = try? axWindow.cgWindowId(), wid != 0 else { continue } - manualWindowUpdatesThrottler.throttleOrProceed(key: "\(wid)") { [weak app] in - guard let app else { return } - AXUIElement.retryAxCallUntilTimeout(context: app.debugId, pid: app.pid, wid: wid, callType: .updateWindowFromManualDiscovery) { [weak app] in - try app?.manuallyUpdateWindow(axWindow, wid) - } + return + } + for axWindow in axWindows { + guard let wid = try? axWindow.cgWindowId(), wid != 0 else { continue } + updateWindowAttributes(axWindow, wid, app) + } + } + } + + /// Unified window attribute fetch + main-thread update. Used by both manual sync and reviewExistingWindows. + static func updateWindowAttributes(_ axWindow: AXUIElement, _ wid: CGWindowID, _ app: Application) { + AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: app.debugId, pid: app.pid) { [weak app] in + guard let app else { return } + guard wid != 0 && wid != TilesPanel.shared.windowNumber else { return } + let level = wid.level() + let isSelf = app.pid == ProcessInfo.processInfo.processIdentifier + let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute]) + let a = try axWindow.attributes(keys) + let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children) + DispatchQueue.main.async { [weak app] in + guard let app else { return } + windowListUpdateThrottler.throttleOrProceed(key: "\(wid)") { + let findOrCreate = Windows.findOrCreate(axWindow, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized) + guard let window = findOrCreate.0 else { return } + var tabStateChanged = false + if tabSiblingTitles != nil || window.tabbedSiblingWids != nil { + tabStateChanged = TabGroup.updateState(window, tabSiblingTitles) + } + if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) { + if findOrCreate.1 { Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } } + App.refreshOpenUiAfterExternalEvent([window]) } } } } } + /// refreshes AX attributes for all known windows, in case notifications were incomplete + static func reviewExistingWindows() { + for window in Windows.list { + guard !window.isWindowlessApp, + let axUiElement = window.axUiElement, + let wid = window.cgWindowId else { continue } + updateWindowAttributes(axUiElement, wid, window.application) + } + } + /// we may not receive a window-destroyed event in some cases: /// * Sequoia bug: https://github.com/lwouis/alt-tab-macos/issues/3589 /// * Logic Pro bug: https://github.com/lwouis/alt-tab-macos/issues/4924 /// this acts as a garbage-collector for windows, to keep our list in-sync with the actual system static func removeZombieWindows() { + // snapshot wids on main thread where Windows.list is safe to read let wIds = Windows.list.compactMap { $0.cgWindowId } guard !wIds.isEmpty else { return } - let rawIds: CFArray = wIds.map { UnsafeRawPointer(bitPattern: UInt($0)) }.withUnsafeBufferPointer { - CFArrayCreate(nil, UnsafeMutablePointer(mutating: $0.baseAddress), $0.count, nil) - } - let descriptions = CGWindowListCreateDescriptionFromArray(rawIds) as? [[CFString: Any]] - let existingWids = descriptions?.compactMap { $0[kCGWindowNumber] } as? [CGWindowID] - guard let existingWids else { return } - let believedAlive = Set(wIds) - let confirmedAlive = Set(existingWids) - let zombies = believedAlive.subtracting(confirmedAlive) - for window in Windows.list.reversed() { - if let wid = window.cgWindowId, zombies.contains(wid) { - Logger.debug { window.debugId } - Windows.removeWindows([window], true) + // CGWindowListCreateDescriptionFromArray is a synchronous WindowServer IPC call; run it off main thread + AXCallScheduler.shared.submit { + let rawIds: CFArray = wIds.map { UnsafeRawPointer(bitPattern: UInt($0)) }.withUnsafeBufferPointer { + CFArrayCreate(nil, UnsafeMutablePointer(mutating: $0.baseAddress), $0.count, nil) + } + let descriptions = CGWindowListCreateDescriptionFromArray(rawIds) as? [[CFString: Any]] + let existingWids = descriptions?.compactMap { $0[kCGWindowNumber] } as? [CGWindowID] + guard let existingWids else { return } + let believedAlive = Set(wIds) + let confirmedAlive = Set(existingWids) + let zombies = believedAlive.subtracting(confirmedAlive) + guard !zombies.isEmpty else { return } + DispatchQueue.main.async { + for window in Windows.list.reversed() { + if let wid = window.cgWindowId, zombies.contains(wid) { + Logger.debug { window.debugId } + Windows.removeWindows([window], true) + } + } } } } @@ -112,8 +156,9 @@ class Applications { } for tApp in terminatingApps { let pid = tApp.processIdentifier - manualAppUpdatesThrottler.removeEntry(withKey: "\(pid)") - AccessibilityEvents.removeThrottlerEntries(pid: pid) + AXCallScheduler.shared.removeEntry(key: "pid-\(pid)") + AXCallScheduler.shared.removeUnresponsivePid(pid) + appListUpdateThrottler.removeEntry(withKey: "\(pid)") } App.refreshOpenUiAfterExternalEvent([]) } @@ -121,8 +166,9 @@ class Applications { static func refreshBadgesAsync() { guard App.appIsBeingUsed && !Preferences.hideAppBadges else { return } badgesThrottler.throttleOrProceed { - AXUIElement.retryAxCallUntilTimeout(callType: .updateDockBadgesFromShowingUi) { - guard let dockPid = (list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid), + let dockPid = list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid + AXCallScheduler.shared.schedule(key: "badges", context: "badges", pid: dockPid) { + guard let dockPid, let axDockChildren = try AXUIElementCreateApplication(dockPid).attributes([kAXChildrenAttribute]).children, let axListAttrs = (axDockChildren.lazy.compactMap { try? $0.attributes([kAXRoleAttribute, kAXChildrenAttribute]) }.first { $0.role == kAXListRole }), let axListChildren = axListAttrs.children else { return } diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index 7b28d606..b185ad7c 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -13,9 +13,6 @@ class BackgroundWork { static var repeatingKeyQueue: LabeledOperationQueue! static var screenshotsQueue: LabeledOperationQueue! static var accessibilityCommandsQueue: LabeledOperationQueue! - static var axCallsFirstAttemptQueue: LabeledOperationQueue! - static var axCallsRetriesQueue: LabeledOperationQueue! - static var axCallsManualDiscoveryQueue: LabeledOperationQueue! static var crashReportsQueue: LabeledOperationQueue! static var permissionsCheckOnTimerQueue: LabeledOperationQueue! static var permissionsSystemCallsQueue: LabeledOperationQueue! @@ -33,16 +30,9 @@ class BackgroundWork { } static func start() { - // calls to focus/close/minimize/etc windows // They are tried once and if they timeout we don't retry. The OS seems to still execute them even if the call timed out accessibilityCommandsQueue = LabeledOperationQueue("axCommands", .userInteractive, 4) - // calls to the AX APIs can block for a long time (e.g. if an app is unresponsive) - // We first try the AX calls on axCallsFirstAttemptQueue. If we get a timeout, we move to axCallsRetriesQueue and retry there for a while - axCallsFirstAttemptQueue = LabeledOperationQueue("axCallsFirst", .userInteractive, 8) - axCallsRetriesQueue = LabeledOperationQueue("axCallsRetry", .userInteractive, 8) - // we separate calls to manuallyUpdateWindows since those can be very heavy and numerous - axCallsManualDiscoveryQueue = LabeledOperationQueue("axCallsManual", .userInteractive, 8) // we time key repeat on a background queue. We handle their consequence on the main-thread repeatingKeyQueue = LabeledOperationQueue("repeatingKey", .userInteractive, 1) // we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially @@ -72,7 +62,7 @@ class BackgroundWork { // useful during development to inspect how many threads are used by AltTab private static func logThreadsAndQueuesOnRepeat() { // if Logger.decideLevel() == .debug { - debugMenu = DebugMenu([screenshotsQueue, accessibilityCommandsQueue, axCallsFirstAttemptQueue, axCallsRetriesQueue, axCallsManualDiscoveryQueue]) + debugMenu = DebugMenu([screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.fastQueue, AXCallScheduler.shared.retryQueue]) debugMenu.orderFront(nil) debugMenu.start() // Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in @@ -83,7 +73,7 @@ class BackgroundWork { } private static func logQueues() -> Void { - let queues = [screenshotsQueue, accessibilityCommandsQueue, axCallsFirstAttemptQueue, axCallsRetriesQueue, crashReportsQueue].compactMap { $0 } + let queues = [screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.fastQueue, AXCallScheduler.shared.retryQueue, crashReportsQueue].compactMap { $0 } var map = [String:Int]() for queue in queues { map[queue.underlyingQueue!.label] = queue.operations.reduce(0) { $1.isExecuting ? $0 + 1 : $0 } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index f18012e3..2f52c20a 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -97,12 +97,12 @@ class Window { private func observeEvents() { AXObserverCreate(application.pid, AccessibilityEvents.axObserverCallback, &axObserver) guard let axObserver else { return } - AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in + AXCallScheduler.shared.schedule(key: "sub-win-\(cgWindowId)", context: debugId, pid: application.pid) { [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, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in + AXCallScheduler.shared.schedule(key: "sub-win-\(cgWindowId)-\(notification)", context: self.debugId, pid: self.application.pid) { [weak self] in try self?.axUiElement!.subscribeToNotification(axObserver, notification) } } @@ -331,7 +331,7 @@ class Window { private func checkIfFocused() { let app = application guard let appAxUiElement = app.axUiElement else { return } - AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: app.pid, wid: cgWindowId, callType: .updateAppFocusedWindowFromWindowInit) { [weak app] in + AXCallScheduler.shared.schedule(key: "wid-\(cgWindowId)-focus", context: debugId, pid: app.pid) { [weak app] in guard let app, let focusedWindow = try appAxUiElement.attributes([kAXFocusedWindowAttribute]).focusedWindow else { return } let focusedWid = try focusedWindow.cgWindowId() DispatchQueue.main.async { diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 601bf6e9..bea644cd 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -42,10 +42,11 @@ class Windows { static func updateIsFullscreenOnCurrentSpace() { let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp } for window in windowsOnCurrentSpace { - AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, wid: window.cgWindowId, callType: .updateWindowFromAxEvent) { [weak window] in + guard let wid = window.cgWindowId, let axUiElement = window.axUiElement else { continue } + AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: window.debugId, pid: window.application.pid) { [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!) + try AccessibilityEvents.handleEventWindow(kAXWindowResizedNotification, wid, window.application.pid, axUiElement) } } } @@ -514,8 +515,8 @@ class Windows { } for w in windows { if let wid = w.cgWindowId { - Applications.manualWindowUpdatesThrottler.removeEntry(withKey: "\(wid)") - AccessibilityEvents.removeThrottlerEntries(wid: wid) + AXCallScheduler.shared.removeEntry(key: "wid-\(wid)") + Applications.windowListUpdateThrottler.removeEntry(withKey: "\(wid)") } // when a tabbed window is removed, update its former siblings' tab group if let siblingWids = w.tabbedSiblingWids { diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 7e80a05d..600f8c3e 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -3,21 +3,12 @@ import ApplicationServices.HIServices.AXUIElement import ApplicationServices.HIServices.AXNotificationConstants class AccessibilityEvents { - private static let throttler = ThrottlerWithKey(delayInMs: 200) - - static func removeThrottlerEntries(wid: CGWindowID) { - throttler.removeEntries(withSuffix: "-wid-\(wid)") - } - - static func removeThrottlerEntries(pid: pid_t) { - throttler.removeEntries(withSuffix: "-pid-\(pid)") - } - static let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ in let type = notificationName as String Logger.debug { type } - AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .entrypointFromAxEvent) { - try handleEvent(type, element) + AXCallScheduler.shared.submit { + do { try handleEvent(type, element) } + catch { Logger.debug { "handleEvent threw for \(type): stale element" } } } } @@ -25,17 +16,22 @@ class AccessibilityEvents { let pid = try element.pid() Logger.debug { "\(type) pid:\(pid)" } if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) { - throttler.throttleOrProceed(key: "\(type)-pid-\(pid)") { - AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, callType: .updateAppFromAxEvent) { - try handleEventApp(type, pid, element) - } + AXCallScheduler.shared.schedule(key: "pid-\(pid)", context: "(pid:\(pid))", pid: pid) { + try handleEventApp(type, pid, element) } } else { let wid = (try? element.cgWindowId()) ?? 0 - throttler.throttleOrProceed(key: "\(type)-wid-\(wid)") { - AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) { - try handleEventWindow(type, wid, pid, element) + guard wid != 0 || type == kAXUIElementDestroyedNotification, + wid != TilesPanel.shared.windowNumber else { return } + if type == kAXUIElementDestroyedNotification { + DispatchQueue.main.async { + Logger.info { "\(type) wid:\(wid) pid:\(pid)" } + windowDestroyed(element, pid, wid) } + return + } + AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: "(pid:\(pid) wid:\(wid))", pid: pid) { + try handleEventWindow(type, wid, pid, element) } } } @@ -44,12 +40,14 @@ class AccessibilityEvents { let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow let wid = try appFocusedWindow?.cgWindowId() DispatchQueue.main.async { - guard let app = Applications.findOrCreate(pid, false) else { return } - Logger.info { "\(type) app:\(app.debugId)" } - if type == kAXApplicationActivatedNotification { - applicationActivated(app, pid, type, appFocusedWindow, wid) - } else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification { - applicationHiddenOrShown(app, pid, type) + Applications.appListUpdateThrottler.throttleOrProceed(key: "\(pid)") { + guard let app = Applications.findOrCreate(pid, false) else { return } + Logger.info { "\(type) app:\(app.debugId)" } + if type == kAXApplicationActivatedNotification { + applicationActivated(app, pid, type, appFocusedWindow, wid) + } else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification { + applicationHiddenOrShown(app, pid, type) + } } } } @@ -61,7 +59,7 @@ class AccessibilityEvents { } 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) \(app.debugId))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) { + AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: "\(type) \(app.debugId))", pid: pid) { try handleEventWindow(kAXFocusedWindowChangedNotification, wid, pid, appFocusedWindow) } } else { @@ -87,15 +85,6 @@ class AccessibilityEvents { } static func handleEventWindow(_ type: String, _ wid: CGWindowID, _ pid: pid_t, _ element: AXUIElement) throws { - guard wid != 0 || type == kAXUIElementDestroyedNotification, - wid != TilesPanel.shared.windowNumber else { return } // don't process events for the thumbnails panel - if type == kAXUIElementDestroyedNotification { - DispatchQueue.main.async { - Logger.info { "\(type) wid:\(wid) pid:\(pid)" } - windowDestroyed(element, pid, wid) - } - return - } let level = wid.level() // if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this let isSelf = pid == ProcessInfo.processInfo.processIdentifier @@ -103,30 +92,32 @@ class AccessibilityEvents { let a = try element.attributes(keys) let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children) DispatchQueue.main.async { - guard let app = Applications.findOrCreate(pid, false) else { return } - Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" } - let findOrCreate = Windows.findOrCreate(element, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized) - guard let window = findOrCreate.0 else { - // we don't know this window, but it got focused, so let's update app.focusedWindow with nil - if type == kAXFocusedWindowChangedNotification && a.role != kAXSheetRole { - app.focusedWindow = nil + Applications.windowListUpdateThrottler.throttleOrProceed(key: "\(wid)") { + guard let app = Applications.findOrCreate(pid, false) else { return } + Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" } + let findOrCreate = Windows.findOrCreate(element, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized) + guard let window = findOrCreate.0 else { + // we don't know this window, but it got focused, so let's update app.focusedWindow with nil + if type == kAXFocusedWindowChangedNotification && a.role != kAXSheetRole { + app.focusedWindow = nil + } + return + } + Logger.debug { "\(type) win:\(window.debugId)" } + var tabStateChanged = false + if tabSiblingTitles != nil || window.tabbedSiblingWids != nil { + tabStateChanged = TabGroup.updateState(window, tabSiblingTitles) + } + if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) { + App.refreshOpenUiAfterExternalEvent([window]) + } + if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification { + focusedWindowChanged(window) + } else if type == kAXWindowResizedNotification || type == kAXWindowMovedNotification { + windowResizedOrMoved(window) + } else if !findOrCreate.1 { + App.refreshOpenUiAfterExternalEvent([window]) } - return - } - Logger.debug { "\(type) win:\(window.debugId)" } - var tabStateChanged = false - if tabSiblingTitles != nil || window.tabbedSiblingWids != nil { - tabStateChanged = TabGroup.updateState(window, tabSiblingTitles) - } - if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) { - App.refreshOpenUiAfterExternalEvent([window]) - } - if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification { - focusedWindowChanged(window) - } else if type == kAXWindowResizedNotification || type == kAXWindowMovedNotification { - windowResizedOrMoved(window) - } else if !findOrCreate.1 { - App.refreshOpenUiAfterExternalEvent([window]) } } } diff --git a/src/logic/events/DockEvents.swift b/src/logic/events/DockEvents.swift index 6a3703c7..e46e5371 100644 --- a/src/logic/events/DockEvents.swift +++ b/src/logic/events/DockEvents.swift @@ -10,7 +10,7 @@ class DockEvents { AXObserverCreate(dockPid, handleEvent, &axObserver) // are we sure we always get a non-nil axObserver? for notification in MissionControlState.allCases { - AXUIElement.retryAxCallUntilTimeout(callType: .subscribeToDockNotification) { + AXCallScheduler.shared.schedule(key: "sub-dock-\(notification.rawValue)", context: "dock", pid: dockPid) { if try axUiElement!.subscribeToNotification(axObserver!, notification.rawValue, nil) { if notification == MissionControlState.showDesktop { Logger.debug { "Subscribed to Dock" }