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)) } } }