feat: improve windows detection

This commit is contained in:
lwouis committed 2025-10-26 16:15:02 +01:00
1 parent 33e5acf0ea
commit cab591eb48
6 files changed
+65 -25

No files matched your search

+7
View File
@@ -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 = "<group>"; };
BF0C8343C1F6FCA82552DCB7 /* TableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = "<group>"; };
BF0C8353097840D7DB1E0E2E /* Markdown.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Markdown.swift; sourceTree = "<group>"; };
BF0C8363D45FECB8D6F8DCD5 /* LightweightTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightweightTimer.swift; sourceTree = "<group>"; };
BF0C83D645B6BB523E50670A /* hr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = hr; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C83E1E5C670A8B03E3612 /* hr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = hr; path = Localizable.strings; sourceTree = "<group>"; };
BF0C83EE282A5FB191918799 /* override_contexts_on_poeditor.ts */ = {isa = PBXFileReference; lastKnownFileType = file.ts; path = override_contexts_on_poeditor.ts; sourceTree = "<group>"; };
@@ -1513,6 +1515,7 @@
BF0C8353097840D7DB1E0E2E /* Markdown.swift */,
BF0C88D101728C6798C7FC07 /* private-apis */,
BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */,
BF0C8363D45FECB8D6F8DCD5 /* LightweightTimer.swift */,
);
path = "api-wrappers";
sourceTree = "<group>";
@@ -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;
};
+17 -10
View File
@@ -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<pid_t, UInt64>()
private static var axCallsRetriesQueueUnresponsiveAppsMap = ConcurrentMap<String, UInt64>()
// some events like window resizing trigger in quick succession. We debounce those
private static var eventsDebounceMap = ConcurrentMap<String, DispatchWorkItem>()
@@ -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<K: Hashable, V> {
+26
View File
@@ -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
}
}
+10 -11
View File
@@ -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()
}
}
}
+4 -4
View File
@@ -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
+1
View File
@@ -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