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.
This commit is contained in:
lwouis committed 2026-05-30 21:51:13 +02:00
1 parent c143ec2f0c
commit a39816e303
4 files changed
+35 -11

No files matched your search

+23 -5
View File
@@ -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 {
+6 -2
View File
@@ -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
}
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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))
}
}
}