fix: windows order was not updating correctly (closes #4754)

(regression introduced in v7.28.0)
This commit is contained in:
lwouis committed 2025-09-19 21:37:24 +02:00
1 parent 27909f1824
commit 1316eb2769
7 files changed
+68 -46

No files matched your search

+17 -6
View File
@@ -21,9 +21,19 @@ extension AXUIElement {
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds)
}
enum AXCallType {
case subscribeToAppNotification
case subscribeToWindowNotification
case subscribeToDockNotification
case updateWindow
case updateAppWindows
case updateDockBadges
case axEventEntrypoint
}
/// 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, 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, block: block) }
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
if let after {
queue!.addOperationAfter(deadline: after, block: closure)
@@ -45,13 +55,14 @@ extension AXUIElement {
}
}
private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, debounceType: DebounceType?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, block: @escaping () throws -> Void) {
private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, debounceType: DebounceType?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, callType: AXCallType, block: @escaping () throws -> Void) {
// attempt the AX call
if (try? block()) != nil {
return
}
// do we already have ongoing retries for this pid?
if let pid {
// 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 {
axCallsRetriesQueueUnresponsiveAppsMap.lock.lock()
let time = axCallsRetriesQueueUnresponsiveAppsMap.map[pid]
axCallsRetriesQueueUnresponsiveAppsMap.lock.unlock()
@@ -81,7 +92,7 @@ extension AXUIElement {
}
// retry
Logger.info(logFromContext(file, function, line, context))
retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, debounceType: debounceType, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, block: block)
retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, debounceType: debounceType, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block)
}
private static func logFromContext(_ file: String, _ function: String, _ line: Int, _ context: String) -> String {
+33 -26
View File
@@ -21,6 +21,15 @@ class Application: NSObject {
var alreadyRequestedToQuit = false
var debugId: String { "(pid:\(String(describing: pid)) \(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName ?? "nil"))" }
static let notifications = [
kAXApplicationActivatedNotification,
kAXMainWindowChangedNotification,
kAXFocusedWindowChangedNotification,
kAXWindowCreatedNotification,
kAXApplicationHiddenNotification,
kAXApplicationShownNotification,
]
init(_ runningApplication: NSRunningApplication) {
self.runningApplication = runningApplication
pid = runningApplication.processIdentifier
@@ -61,15 +70,19 @@ class Application: NSObject {
}
func observeEventsIfEligible() {
if runningApplication.activationPolicy != .prohibited && axUiElement == nil {
axUiElement = AXUIElementCreateApplication(pid)
AXObserverCreate(pid, axObserverCallback, &axObserver)
if runningApplication.activationPolicy != .prohibited && !isReallyFinishedLaunching {
if axUiElement == nil {
axUiElement = AXUIElementCreateApplication(pid)
}
if axObserver == nil {
AXObserverCreate(pid, axObserverCallback, &axObserver)
}
observeEvents()
}
}
func manuallyUpdateWindows() {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid) { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .updateAppWindows) { [weak self] in
guard let self else { return }
var atLeastOneActualWindow = false
guard let axWindows = try self.axUiElement?.allWindows(self.pid) else { return }
@@ -163,29 +176,23 @@ class Application: NSObject {
private func observeEvents() {
guard let axObserver else { return }
for notification in [
kAXApplicationActivatedNotification,
kAXMainWindowChangedNotification,
kAXFocusedWindowChangedNotification,
kAXWindowCreatedNotification,
kAXApplicationHiddenNotification,
kAXApplicationShownNotification,
] {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid) { [weak self] in
guard let self else { return }
if try self.axUiElement!.subscribeToNotification(axObserver, notification) {
if notification == kAXApplicationActivatedNotification {
Logger.debug("Subscribed to app", self.debugId)
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .subscribeToAppNotification) { [weak self] in
guard let self 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 }
// 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()
}
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// 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()
}
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ class Applications {
static func refreshBadgesAsync() {
if !App.app.appIsBeingUsed || Preferences.hideAppBadges { return }
AXUIElement.retryAxCallUntilTimeout {
AXUIElement.retryAxCallUntilTimeout(callType: .updateDockBadges) {
if let dockPid = (list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid),
let axList = (try AXUIElementCreateApplication(dockPid).children()?.first { try $0.role() == kAXListRole }),
let axAppDockItem = (try axList.children()?.filter { try $0.subrole() == kAXApplicationDockItemSubrole && ($0.appIsRunning() ?? false) }) {
+11 -7
View File
@@ -48,6 +48,9 @@ class Window {
Window.globalCreationCounter += 1
creationOrder = Window.globalCreationCounter
application.removeWindowslessAppWindow()
// the app may have timed out trying to subscribe to app notifications
// It may be responsive now since it has a window; we attempt again
application.observeEventsIfEligible()
checkIfFocused(application, wid)
Logger.debug(debugId)
observeEvents()
@@ -68,7 +71,7 @@ class Window {
/// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow
/// workaround: we check and possibly do it here
func checkIfFocused(_ application: Application, _ wid: CGWindowID) {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid) {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .updateWindow) {
let focusedWid = try application.axUiElement?.focusedWindow()?.cgWindowId()
if wid == focusedWid {
application.focusedWindow = self
@@ -85,12 +88,13 @@ class Window {
private func observeEvents() {
AXObserverCreate(application.pid, axObserverCallback, &axObserver)
guard let axObserver else { return }
for notification in Window.notifications {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid) { [weak self] in
guard let self else { return }
if try self.axUiElement!.subscribeToNotification(axObserver, notification) {
if notification == kAXUIElementDestroyedNotification {
Logger.debug("Subscribed to window", self.debugId)
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .subscribeToWindowNotification) { [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, callType: .subscribeToWindowNotification) { [weak self] in
try self?.axUiElement!.subscribeToNotification(axObserver, notification)
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ class Windows {
static func updateIsFullscreenOnCurrentSpace() {
let windowsOnCurrentSpace = Windows.list.filter { !$0.isWindowlessApp }
for window in windowsOnCurrentSpace {
AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay) { [weak window] in
AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, callType: .updateWindow) { [weak window] in
guard let window else { return }
try updateWindowSizeAndPositionAndFullscreen(window.axUiElement!, window.cgWindowId!, window)
}
+4 -4
View File
@@ -5,7 +5,7 @@ import ApplicationServices.HIServices.AXNotificationConstants
let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ in
let type = notificationName as String
Logger.debug(type)
AXUIElement.retryAxCallUntilTimeout { try handleEvent(type, element) }
AXUIElement.retryAxCallUntilTimeout(callType: .axEventEntrypoint) { try handleEvent(type, element) }
}
fileprivate func handleEvent(_ type: String, _ element: AXUIElement) throws {
@@ -100,7 +100,7 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro
if let windows = Windows.updateLastFocus(element, wid) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
} else {
AXUIElement.retryAxCallUntilTimeout(context: "wid:\(wid) pid:\(pid)", pid: pid) {
AXUIElement.retryAxCallUntilTimeout(context: "wid:\(wid) pid:\(pid)", pid: pid, callType: .updateWindow) {
if let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
@@ -156,7 +156,7 @@ fileprivate func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ ty
fileprivate func windowTitleChanged(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowTitleChanged, pid: pid, wid: wid) {
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowTitleChanged, pid: pid, wid: wid, callType: .updateWindow) {
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), title != window.title {
@@ -172,7 +172,7 @@ fileprivate func windowTitleChanged(_ element: AXUIElement, _ pid: pid_t) throws
fileprivate func windowResizedOrMoved(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowResizedOrMoved, pid: pid, wid: wid) {
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowResizedOrMoved, pid: pid, wid: wid, callType: .updateWindow) {
try updateWindowSizeAndPositionAndFullscreen(element, wid, nil)
}
}
+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 {
AXUIElement.retryAxCallUntilTimeout(callType: .subscribeToDockNotification) {
if try axUiElement!.subscribeToNotification(axObserver!, notification.rawValue, nil) {
if notification == MissionControlState.showDesktop {
Logger.debug("Subscribed to Dock")