feat: improve windows detection
This commit is contained in:
1 parent
33e5acf0ea
commit
cab591eb48
6 files changed
+65
-25
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user