feat: improved detection of windows and apps

* find more windows and apps (e.g. apps that take 30s to launch)
* keep windows and apps more up-to-date
* more efficient, uses fewer resources
This commit is contained in:
lwouis
2026-03-28 11:53:44 +01:00
parent 170bd673e0
commit 7a2a5482ae
11 changed files with 411 additions and 199 deletions
+1
View File
@@ -14,3 +14,4 @@ codesign.conf
codesign.crt
codesign.key
codesign.p12
/.claude/worktrees/
+4
View File
@@ -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 = "<group>"; };
BF0C89F6EA16DD7322626E20 /* FUNDING.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = FUNDING.yml; sourceTree = "<group>"; };
BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = "<group>"; };
AA0C8A000000000000000001 /* AXCallScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXCallScheduler.swift; sourceTree = "<group>"; };
BF0C8A261AAA42191426A587 /* run_tests.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = run_tests.sh; sourceTree = "<group>"; };
BF0C8A3DAEA5B956593AF97A /* kn */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = kn; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8A479D2B7FB80531F037 /* PopupButtonLikeSystemSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PopupButtonLikeSystemSettings.swift; sourceTree = "<group>"; };
@@ -1433,6 +1435,7 @@
BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */,
BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */,
BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */,
AA0C8A000000000000000001 /* AXCallScheduler.swift */,
);
path = logic;
sourceTree = "<group>";
@@ -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 */,
+1 -53
View File
@@ -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
+254
View File
@@ -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<pid_t>()
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)"
}
}
+2 -25
View File
@@ -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
+89 -43
View File
@@ -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 }
+2 -12
View File
@@ -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 }
+3 -3
View File
@@ -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 {
+5 -4
View File
@@ -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 {
+49 -58
View File
@@ -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])
}
}
}
+1 -1
View File
@@ -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" }