From a39816e3034e9aba0475997d0dd70e7745a02f95 Mon Sep 17 00:00:00 2001 From: lwouis Date: Sat, 30 May 2026 21:51:13 +0200 Subject: [PATCH] perf: carry (pid, wid) in AX subscription refcon to skip identity calls AXObserverAddNotification's refcon was always nil. Bake the owning identity into it at subscribe time so the AX callback gets (pid, wid) for free, instead of an element.pid()/cgWindowId() round-trip per event. Window-level subscriptions carry a real wid; app-level subscriptions carry wid 0, so focus/main/created still resolve their per-event subject window from the element. Notably this gives a reliable wid for kAXUIElementDestroyed, which previously had to query an already-dead element. --- src/events/AccessibilityEvents.swift | 28 +++++++++++++++++++----- src/macos/api-wrappers/AXUIElement.swift | 8 +++++-- src/switcher/state/Application.swift | 5 +++-- src/switcher/state/Window.swift | 5 +++-- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/events/AccessibilityEvents.swift b/src/events/AccessibilityEvents.swift index 440c9051..b4e94699 100644 --- a/src/events/AccessibilityEvents.swift +++ b/src/events/AccessibilityEvents.swift @@ -3,15 +3,31 @@ import ApplicationServices.HIServices.AXUIElement import ApplicationServices.HIServices.AXNotificationConstants class AccessibilityEvents { - static let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ in + static let axObserverCallback: AXObserverCallback = { _, element, notificationName, refcon in let type = notificationName as String Logger.debug { type } + // The subscription baked the owning (pid, wid) into `refcon`, so we get the identity without an + // AX round-trip. Window-level subs carry a real wid; app-level subs carry wid 0 (their subject + // window varies per event, so focus/main/created still resolve it from `element`). + let (pid, wid) = decodeSubscriptionRefcon(refcon) AXCallScheduler.shared.submit { - do { try handleEvent(type, element) } + do { try handleEvent(type, element, pid, wid) } catch { Logger.debug { "handleEvent threw for \(type): stale element" } } } } + /// Bake (pid, wid) into a subscription's refcon (see `AXUIElement.subscribeToNotification`). Window-level + /// subscriptions pass a real wid; app-level subscriptions pass wid 0 (their subject window is per-event). + static func subscriptionRefcon(_ pid: pid_t, _ wid: CGWindowID = 0) -> UnsafeMutableRawPointer? { + let packed = (UInt(UInt32(bitPattern: pid)) << 32) | UInt(wid) + return UnsafeMutableRawPointer(bitPattern: packed) + } + + private static func decodeSubscriptionRefcon(_ refcon: UnsafeMutableRawPointer?) -> (pid: pid_t, wid: CGWindowID) { + let packed = UInt(bitPattern: refcon) + return (pid_t(bitPattern: UInt32(truncatingIfNeeded: packed >> 32)), CGWindowID(truncatingIfNeeded: packed)) + } + /// events that hit the same branch in `handleEventWindow` are interchangeable, so they share a throttle bucket. /// keeping focus events in their own bucket prevents geometry/title bursts from clobbering the focus side-effect /// that updates `Window.lastFocusOrder` (root cause of #5492 / #5580). @@ -23,15 +39,17 @@ class AccessibilityEvents { } } - private static func handleEvent(_ type: String, _ element: AXUIElement) throws { - let pid = try element.pid() + private static func handleEvent(_ type: String, _ element: AXUIElement, _ refconPid: pid_t, _ refconWid: CGWindowID) throws { + // identity comes from the subscription refcon; fall back to an AX round-trip only when it didn't + // carry it — pid for legacy/nil-refcon subs, wid for the app-level focus/main/created events. + let pid = refconPid != 0 ? refconPid : (try element.pid()) Logger.debug { "\(type) pid:\(pid)" } if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) { AXCallScheduler.shared.schedule(key: "pid-\(pid)", context: "(pid:\(pid))", pid: pid) { try handleEventApp(type, pid, element) } } else { - let wid = (try? element.cgWindowId()) ?? 0 + let wid = refconWid != 0 ? refconWid : ((try? element.cgWindowId()) ?? 0) guard wid != 0 || type == kAXUIElementDestroyedNotification, wid != TilesPanel.shared.windowNumber else { return } if type == kAXUIElementDestroyedNotification { diff --git a/src/macos/api-wrappers/AXUIElement.swift b/src/macos/api-wrappers/AXUIElement.swift index 9df5c28a..73e97d9b 100644 --- a/src/macos/api-wrappers/AXUIElement.swift +++ b/src/macos/api-wrappers/AXUIElement.swift @@ -24,8 +24,12 @@ extension AXUIElement { } @discardableResult - func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool { - let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil) + func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ refcon: UnsafeMutableRawPointer? = nil) throws -> Bool { + // `refcon` is handed back verbatim to the AX callback for every delivery of this (element, + // notification) pair. We bake the owning (pid, wid) in here so the callback gets the identity + // for free — no `pid()` / `cgWindowId()` round-trip per event (and a reliable wid even for a + // window that's already been destroyed). See `AccessibilityEvents.subscriptionRefcon`. + let result = AXObserverAddNotification(axObserver, self, notification as CFString, refcon) if result == .success || result == .notificationAlreadyRegistered { return true } diff --git a/src/switcher/state/Application.swift b/src/switcher/state/Application.swift index 75e27a5b..d70ba616 100644 --- a/src/switcher/state/Application.swift +++ b/src/switcher/state/Application.swift @@ -161,7 +161,7 @@ class Application: NSObject { guard let axObserver else { return } AXCallScheduler.shared.schedule(key: "sub-app-\(self.pid)", context: debugId, pid: self.pid) { [weak self] in guard let self, !self.isReallyFinishedLaunching else { return } - if try self.axUiElement!.subscribeToNotification(axObserver, Application.notifications.first!) { + if try self.axUiElement!.subscribeToNotification(axObserver, Application.notifications.first!, AccessibilityEvents.subscriptionRefcon(self.pid)) { Logger.debug { "Subscribed to app: \(self.debugId)" } if !self.isReallyFinishedLaunching { // some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete @@ -170,7 +170,8 @@ class Application: NSObject { self.isReallyFinishedLaunching = true for notification in Application.notifications.dropFirst() { AXCallScheduler.shared.schedule(key: "sub-app-\(self.pid)-\(notification)", context: self.debugId, pid: self.pid) { [weak self] in - try self?.axUiElement!.subscribeToNotification(axObserver, notification) + guard let self else { return } + try self.axUiElement!.subscribeToNotification(axObserver, notification, AccessibilityEvents.subscriptionRefcon(self.pid)) } } DispatchQueue.main.async { [weak self] in diff --git a/src/switcher/state/Window.swift b/src/switcher/state/Window.swift index ce9465c3..d52cf440 100644 --- a/src/switcher/state/Window.swift +++ b/src/switcher/state/Window.swift @@ -136,11 +136,12 @@ class Window { guard let axObserver else { return } 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!) { + if try self.axUiElement!.subscribeToNotification(axObserver, Window.notifications.first!, AccessibilityEvents.subscriptionRefcon(self.application.pid, self.cgWindowId ?? 0)) { Logger.debug { "Subscribed to window: \(self.debugId)" } for notification in Window.notifications.dropFirst() { AXCallScheduler.shared.schedule(key: "sub-win-\(cgWindowId)-\(notification)", context: self.debugId, pid: self.application.pid) { [weak self] in - try self?.axUiElement!.subscribeToNotification(axObserver, notification) + guard let self else { return } + try self.axUiElement!.subscribeToNotification(axObserver, notification, AccessibilityEvents.subscriptionRefcon(self.application.pid, self.cgWindowId ?? 0)) } } }