From cab591eb488e9d98306ad1c86a9716d3c8f64b01 Mon Sep 17 00:00:00 2001 From: lwouis Date: Thu, 25 Sep 2025 13:57:45 +0200 Subject: [PATCH] feat: improve windows detection --- alt-tab-macos.xcodeproj/project.pbxproj | 7 +++++++ src/api-wrappers/AXUIElement.swift | 27 ++++++++++++++++--------- src/api-wrappers/LightweightTimer.swift | 26 ++++++++++++++++++++++++ src/logic/Application.swift | 21 +++++++++---------- src/logic/BackgroundWork.swift | 8 ++++---- src/ui/App.swift | 1 + 6 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 src/api-wrappers/LightweightTimer.swift diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 7af63457..821e71bb 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -181,6 +181,7 @@ BF0C8EF58A07D9752A6392FC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8D928E2ABECB92738113 /* Localizable.strings */; }; BF0C8F303887F8454452F85C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C843F6EF209A70B06FE19 /* Localizable.strings */; }; BF0C8F3526BC393290D5BE59 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C81EC1C5F3052A75C7D7B /* InfoPlist.strings */; }; + BF0C8F40C2D0FF7601D926D3 /* LightweightTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8363D45FECB8D6F8DCD5 /* LightweightTimer.swift */; }; BF0C8F9B8014AD21E68B1349 /* HelperExtensionsTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */; }; BF0C8FF2A4B00D409494EA80 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C88D79FA0C773D94F351A /* InfoPlist.strings */; }; D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */; }; @@ -388,6 +389,7 @@ BF0C833D471343CEA3DEFE56 /* icon_512x512@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_512x512@2x.png"; sourceTree = ""; }; BF0C8343C1F6FCA82552DCB7 /* TableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; BF0C8353097840D7DB1E0E2E /* Markdown.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Markdown.swift; sourceTree = ""; }; + BF0C8363D45FECB8D6F8DCD5 /* LightweightTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightweightTimer.swift; sourceTree = ""; }; BF0C83D645B6BB523E50670A /* hr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = hr; path = InfoPlist.strings; sourceTree = ""; }; BF0C83E1E5C670A8B03E3612 /* hr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = hr; path = Localizable.strings; sourceTree = ""; }; BF0C83EE282A5FB191918799 /* override_contexts_on_poeditor.ts */ = {isa = PBXFileReference; lastKnownFileType = file.ts; path = override_contexts_on_poeditor.ts; sourceTree = ""; }; @@ -1513,6 +1515,7 @@ BF0C8353097840D7DB1E0E2E /* Markdown.swift */, BF0C88D101728C6798C7FC07 /* private-apis */, BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */, + BF0C8363D45FECB8D6F8DCD5 /* LightweightTimer.swift */, ); path = "api-wrappers"; sourceTree = ""; @@ -2434,6 +2437,7 @@ BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */, BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */, BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */, + BF0C8F40C2D0FF7601D926D3 /* LightweightTimer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3342,6 +3346,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "${TEAM_ID}"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3433,6 +3438,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D04BA62D5EACAAAD7BCD3E79 /* release.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = "${TEAM_ID}"; }; name = Release; }; @@ -3440,6 +3446,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D04BA1232AFEEFE90D5CC827 /* debug.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = "${TEAM_ID}"; }; name = Debug; }; diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index c3342321..ecaa3b60 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -13,7 +13,7 @@ extension AXUIElement { // if an app times out our AX calls, we retry for 6s then give up 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() + private static var axCallsRetriesQueueUnresponsiveAppsMap = ConcurrentMap() // some events like window resizing trigger in quick succession. We debounce those private static var eventsDebounceMap = ConcurrentMap() @@ -21,7 +21,7 @@ extension AXUIElement { AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds) } - enum AXCallType { + enum AXCallType: Int { case subscribeToAppNotification case subscribeToWindowNotification case subscribeToDockNotification @@ -34,7 +34,7 @@ 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 = retriesQueue ? BackgroundWork.axCallsRetriesQueue : BackgroundWork.axCallsFirstAttemptQueue + let queue = callType == .updateAppWindows ? BackgroundWork.axCallsManualDiscoveryQueue : (retriesQueue ? BackgroundWork.axCallsRetriesQueue : BackgroundWork.axCallsFirstAttemptQueue) if let after { queue!.addOperationAfter(deadline: after, block: closure) } else if let debounceType, let wid { @@ -63,13 +63,14 @@ extension AXUIElement { // 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)" axCallsRetriesQueueUnresponsiveAppsMap.lock.lock() - let time = axCallsRetriesQueueUnresponsiveAppsMap.map[pid] + let time = axCallsRetriesQueueUnresponsiveAppsMap.map[unresponsiveAppsMapKey] axCallsRetriesQueueUnresponsiveAppsMap.lock.unlock() if let time { if startTimeInNanoseconds > time { // new most recent call; replace and retry - axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[pid] = startTimeInNanoseconds } + axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] = startTimeInNanoseconds } } else if startTimeInNanoseconds == time { // most recent call; retry } else { @@ -78,15 +79,16 @@ extension AXUIElement { } } else { // first call; set and retry - axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[pid] = startTimeInNanoseconds } + axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] = startTimeInNanoseconds } } } // should we 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)) - if let pid { - axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0.removeValue(forKey: pid) } + if let pid, callType == .updateWindow || callType == .updateAppWindows { + let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)" + axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0.removeValue(forKey: unresponsiveAppsMapKey) } } return } @@ -254,6 +256,8 @@ extension AXUIElement { remoteToken.replaceSubrange(8..<12, with: withUnsafeBytes(of: Int32(0x636f636f)) { Data($0) }) var axWindows = [AXUIElement]() // we iterate to 1000 as a tradeoff between performance, and missing windows of long-lived processes + // different apps can take widely different time for this to complete. We stop iterating if we time out + let timer = LightweightTimer() for axUiElementId: AXUIElementID in 0..<1000 { remoteToken.replaceSubrange(12..<20, with: withUnsafeBytes(of: axUiElementId) { Data($0) }) if let axUiElement = _AXUIElementCreateWithRemoteToken(remoteToken as CFData)?.takeRetainedValue(), @@ -261,6 +265,9 @@ extension AXUIElement { [kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) { axWindows.append(axUiElement) } + if timer.hasElapsed(milliseconds: 100) { + return axWindows + } } return axWindows } @@ -474,8 +481,8 @@ enum AxError: Error { typealias AXUIElementID = UInt64 enum DebounceType: Int { - case windowResizedOrMoved = 0 - case windowTitleChanged = 1 + case windowResizedOrMoved + case windowTitleChanged } class ConcurrentMap { diff --git a/src/api-wrappers/LightweightTimer.swift b/src/api-wrappers/LightweightTimer.swift new file mode 100644 index 00000000..1ce03c22 --- /dev/null +++ b/src/api-wrappers/LightweightTimer.swift @@ -0,0 +1,26 @@ +import Darwin + +class LightweightTimer { + private static let timebaseRatio: Double = { + var info = mach_timebase_info() + mach_timebase_info(&info) + return Double(info.numer) / Double(info.denom) + }() + + private let start: UInt64 + + var elapsedMilliseconds: Double { + let now = mach_absolute_time() + let nanos = Double(now - start) * Self.timebaseRatio + return nanos / 1_000_000 // ns → ms + } + + init() { + start = mach_absolute_time() + } + + /// Returns true if the given number of milliseconds has passed + func hasElapsed(milliseconds: Double) -> Bool { + elapsedMilliseconds >= milliseconds + } +} diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 3fffeee8..395e0cfe 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -177,22 +177,21 @@ class Application: NSObject { private func observeEvents() { guard let axObserver else { return } AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .subscribeToAppNotification) { [weak self] in - guard let self else { return } + guard let self, !self.isReallyFinishedLaunching else { return } if try self.axUiElement!.subscribeToNotification(axObserver, Application.notifications.first!) { Logger.debug("Subscribed to app", self.debugId) - for notification in Application.notifications.dropFirst() { - AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.pid, callType: .subscribeToAppNotification) { [weak self] in - try self?.axUiElement!.subscribeToNotification(axObserver, notification) - } - } - DispatchQueue.main.async { [weak self] in - guard let self else { return } + if !self.isReallyFinishedLaunching { // some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete // we consider them ready when the first subscription succeeds // windows opened before that point won't send a notification, so check those windows manually here - if !self.isReallyFinishedLaunching { - self.isReallyFinishedLaunching = true - self.manuallyUpdateWindows() + self.isReallyFinishedLaunching = true + for notification in Application.notifications.dropFirst() { + AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.pid, callType: .subscribeToAppNotification) { [weak self] in + try self?.axUiElement!.subscribeToNotification(axObserver, notification) + } + } + DispatchQueue.main.async { [weak self] in + self?.manuallyUpdateWindows() } } } diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index 7ee5b8f6..ec5feddf 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -7,6 +7,7 @@ class BackgroundWork { static var accessibilityCommandsQueue: LabeledOperationQueue! static var axCallsFirstAttemptQueue: LabeledOperationQueue! static var axCallsRetriesQueue: LabeledOperationQueue! + static var axCallsManualDiscoveryQueue: LabeledOperationQueue! static var crashReportsQueue: LabeledOperationQueue! // we use Threads to observe events in sequence static var accessibilityEventsThread: BackgroundThreadWithRunLoop! @@ -19,9 +20,6 @@ class BackgroundWork { private static var totalPotentialThreadCount = 0 static func start() { - // screenshots are taken on a serial DispatchQueue. They used to be taken on the .global() concurrent queue. - // it could hang the app since the screenshot OS calls are slow. It would hang or crash with this error: - // >Processes reached dispatch thread soft limit (64) screenshotsQueue = LabeledOperationQueue("screenshotsQueue", .userInteractive, 8) // 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 @@ -30,6 +28,8 @@ class BackgroundWork { // 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("axCallsFirstAttemptQueue", .userInteractive, 8) axCallsRetriesQueue = LabeledOperationQueue("axCallsRetriesQueue", .userInteractive, 8) + // we separate calls to manuallyUpdateWindows since those can be very heavy and numerous + axCallsManualDiscoveryQueue = LabeledOperationQueue("axCallsManualDiscoveryQueue", .userInteractive, 8) // we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive) // we listen to as any keyboard events as possible on a background thread, as it's more available/reliable than the main thread @@ -58,7 +58,7 @@ class BackgroundWork { static func addPotentialThreadCount(_ additionalCount: Int) { totalPotentialThreadCount += additionalCount // a macos process has a soft limit of 64 threads. We need to be careful to don't spawn too many threads through DispatchQueues - assert(totalPotentialThreadCount <= 36) + assert(totalPotentialThreadCount <= 43) } // useful during development to inspect how many threads are used by AltTab diff --git a/src/ui/App.swift b/src/ui/App.swift index 5914278f..c9fe2e67 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -239,6 +239,7 @@ class App: AppCenterApplication { App.app.appIsBeingUsed = true if isFirstSummon || shortcutIndex != self.shortcutIndex { NSScreen.updatePreferred() + Applications.manuallyRefreshAllWindows() if isVeryFirstSummon { Windows.sortByLevel() isVeryFirstSummon = false