From 0a155257c5a3be8427ff1316bbcc65f33cc9fd96 Mon Sep 17 00:00:00 2001 From: "seven.zeng" <98709474@qq.com> Date: Mon, 31 Aug 2026 14:49:38 +0800 Subject: [PATCH] Fix notification watcher freezes and energy usage --- Sources/OTifierApp/Info.plist | 4 +- Sources/OTifierLib/NotificationWatcher.swift | 423 ++++++++++++++---- Tests/OTifierLibTests/OTPExtractorTests.swift | 23 + 3 files changed, 365 insertions(+), 85 deletions(-) diff --git a/Sources/OTifierApp/Info.plist b/Sources/OTifierApp/Info.plist index 0e79c6d..cf9f749 100644 --- a/Sources/OTifierApp/Info.plist +++ b/Sources/OTifierApp/Info.plist @@ -7,9 +7,9 @@ CFBundleIdentifier com.otifier.Otifier CFBundleVersion - 1.0.0 + 1.0.1 CFBundleShortVersionString - 1.0.0 + 1.0.1 CFBundleExecutable Otifier CFBundleIconFile diff --git a/Sources/OTifierLib/NotificationWatcher.swift b/Sources/OTifierLib/NotificationWatcher.swift index 5310d4e..ffede63 100644 --- a/Sources/OTifierLib/NotificationWatcher.swift +++ b/Sources/OTifierLib/NotificationWatcher.swift @@ -1,120 +1,305 @@ import Cocoa import ApplicationServices -/// Watches for macOS notification banners via the Accessibility API -/// and extracts OTP codes from their text content. -class NotificationWatcher { - private var pollTimer: Timer? - // FIFO of recently-seen notification texts. Array-backed so the oldest - // entry actually gets evicted; Set.removeFirst would drop a random one. +private let notificationCenterBundleID = "com.apple.notificationcenterui" +private let axMessagingTimeout: Float = 0.5 +private let observerBackstopInterval: TimeInterval = 4 +private let fallbackPollInterval: TimeInterval = 1 +private let fullScanSafetyInterval: TimeInterval = 30 + +private let notificationObserverCallback: AXObserverCallback = { _, _, _, refcon in + guard let refcon else { return } + let watcher = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + watcher.notificationCenterDidChange() +} + +/// A privacy-preserving description of a Notification Center window. It only +/// contains geometry and window identifiers; notification text is never cached. +struct NotificationWindowFingerprint: Equatable { + let values: [String] +} + +/// Decides when the comparatively expensive Accessibility-tree scan is needed. +/// Window changes trigger a scan plus one follow-up (banner contents may be +/// populated just after the window is created); otherwise only a safety scan is +/// allowed through. +struct NotificationScanGate { + private(set) var previousFingerprint: NotificationWindowFingerprint? + private(set) var followUpScansRemaining = 0 + private(set) var lastScanAt = Date.distantPast + + mutating func shouldScan( + fingerprint: NotificationWindowFingerprint, + now: Date, + safetyInterval: TimeInterval = fullScanSafetyInterval + ) -> Bool { + if previousFingerprint != fingerprint { + previousFingerprint = fingerprint + followUpScansRemaining = 1 + lastScanAt = now + return true + } + + if followUpScansRemaining > 0 { + followUpScansRemaining -= 1 + lastScanAt = now + return true + } + + if now.timeIntervalSince(lastScanAt) >= safetyInterval { + lastScanAt = now + return true + } + + return false + } + + mutating func recordEventScan(at date: Date) { + lastScanAt = date + } + + mutating func reset() { + previousFingerprint = nil + followUpScansRemaining = 0 + lastScanAt = .distantPast + } +} + +/// Watches macOS notification banners through the Accessibility API and +/// extracts OTP codes from their text content. +/// +/// Accessibility calls are synchronous IPC. Notification Center can be slow or +/// temporarily unresponsive, so all AX work is isolated on a utility queue and +/// given a short messaging timeout. The UI thread therefore remains responsive +/// even if Notification Center does not answer. +// Mutable monitoring state is serialized on workerQueue. Callback properties are +// configured before start() and only read by that queue. +final class NotificationWatcher: @unchecked Sendable { + private let workerQueue = DispatchQueue( + label: "com.otifier.notification-watcher", + qos: .utility, + autoreleaseFrequency: .workItem + ) + + // The properties below are confined to workerQueue. + private var pollSource: DispatchSourceTimer? + private var eventScanWorkItem: DispatchWorkItem? + private var axObserver: AXObserver? + private var observedApplication: AXUIElement? + private var observedPID: pid_t? + private var isRunning = false + private var scanGate = NotificationScanGate() private var lastSeenTexts: [String] = [] - private let maxCacheSize = 50 private var lastPermissionCheck = Date.distantPast - private let permissionCheckInterval: TimeInterval = 5 + + private let maxCacheSize = 50 + private let permissionCheckInterval: TimeInterval = 10 + private let maxTreeDepth = 12 + private let maxElementsPerScan = 250 + var onOTPDetected: ((String, String) -> Void)? // (otp, sourceText) /// Called once if Accessibility permission is revoked while running. /// The watcher stops itself before invoking this. var onAXPermissionLost: (() -> Void)? - init() {} + func start() { + workerQueue.async { [weak self] in + guard let self, !self.isRunning else { return } + self.isRunning = true + self.scanGate.reset() + self.lastPermissionCheck = .distantPast - /// The AX element for the Notification Center process. Match by bundle id - /// first — `localizedName` varies by user locale. - private func getNotificationCenterApp() -> AXUIElement? { - for app in NSWorkspace.shared.runningApplications { - if app.bundleIdentifier == "com.apple.notificationcenterui" { - return AXUIElementCreateApplication(app.processIdentifier) - } + // A global process timeout also applies to child AX elements created + // while traversing Notification Center's hierarchy. + let systemWideElement = AXUIElementCreateSystemWide() + _ = AXUIElementSetMessagingTimeout(systemWideElement, axMessagingTimeout) + + self.refreshNotificationCenterConnection() + self.startBackstopTimer() + self.pollNotificationsIfNeeded(force: true) + } + } + + func stop() { + workerQueue.async { [weak self] in + self?.stopOnWorkerQueue() } - return nil } - /// Recursively walk the AX tree and collect all text values - private func collectTexts(from element: AXUIElement, depth: Int = 0, maxDepth: Int = 15) -> [String] { - guard depth < maxDepth else { return [] } - var texts: [String] = [] + deinit { + pollSource?.setEventHandler {} + pollSource?.cancel() + eventScanWorkItem?.cancel() + removeAXObserver() + } - let textAttributes: [String] = [ - kAXValueAttribute as String, - kAXTitleAttribute as String, - kAXDescriptionAttribute as String, - kAXHelpAttribute as String, - kAXRoleDescriptionAttribute as String, - ] + private func stopOnWorkerQueue() { + guard isRunning else { return } + isRunning = false - for attr in textAttributes { - var value: AnyObject? - let result = AXUIElementCopyAttributeValue(element, attr as CFString, &value) - if result == .success, let str = value as? String, !str.isEmpty { - texts.append(str) - } + pollSource?.setEventHandler {} + pollSource?.cancel() + pollSource = nil + eventScanWorkItem?.cancel() + eventScanWorkItem = nil + removeAXObserver() + observedPID = nil + scanGate.reset() + } + + private func startBackstopTimer() { + pollSource?.setEventHandler {} + pollSource?.cancel() + + let interval = axObserver == nil ? fallbackPollInterval : observerBackstopInterval + let timer = DispatchSource.makeTimerSource(queue: workerQueue) + timer.schedule( + deadline: .now() + interval, + repeating: interval, + leeway: .milliseconds(Int(interval * 200)) + ) + timer.setEventHandler { [weak self] in + self?.pollNotificationsIfNeeded(force: false) } + pollSource = timer + timer.resume() + } + + /// Called by the AX observer's main-run-loop callback. Actual processing is + /// coalesced and dispatched to the utility queue. + fileprivate func notificationCenterDidChange() { + workerQueue.async { [weak self] in + guard let self, self.isRunning else { return } - var children: AnyObject? - let childResult = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &children) - if childResult == .success, let childArray = children as? [AXUIElement] { - for child in childArray { - texts.append(contentsOf: collectTexts(from: child, depth: depth + 1, maxDepth: maxDepth)) + // One banner can create several AX elements. Debouncing collapses + // that burst into a single tree traversal after text is populated. + self.eventScanWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, self.isRunning else { return } + self.scanGate.recordEventScan(at: Date()) + self.performNotificationScan() } + self.eventScanWorkItem = item + self.workerQueue.asyncAfter(deadline: .now() + .milliseconds(200), execute: item) } - - return texts } - /// Get text from all Notification Center windows and children (banner notifications) - private func getNotificationTexts() -> [String] { - guard let ncApp = getNotificationCenterApp() else { return [] } + private func pollNotificationsIfNeeded(force: Bool) { + guard isRunning else { return } - var allTexts: [String] = [] + let now = Date() + if now.timeIntervalSince(lastPermissionCheck) >= permissionCheckInterval { + lastPermissionCheck = now + if !AXIsProcessTrusted() { + let callback = onAXPermissionLost + stopOnWorkerQueue() + callback?() + return + } + } - // Check windows (banners appear as windows on some macOS versions) - var windowsValue: AnyObject? - if AXUIElementCopyAttributeValue(ncApp, kAXWindowsAttribute as CFString, &windowsValue) == .success, - let windows = windowsValue as? [AXUIElement] { - for window in windows { - allTexts.append(contentsOf: collectTexts(from: window)) + if observedPID == nil || NSRunningApplication(processIdentifier: observedPID!)?.isTerminated != false { + let previouslyObserved = observedPID + refreshNotificationCenterConnection() + if observedPID != previouslyObserved { + startBackstopTimer() } } - // Also check direct children (banners may appear here on newer macOS) - if allTexts.isEmpty { - var childrenValue: AnyObject? - if AXUIElementCopyAttributeValue(ncApp, kAXChildrenAttribute as CFString, &childrenValue) == .success, - let children = childrenValue as? [AXUIElement] { - for child in children { - // Skip the menu bar — we only want notification content - var roleValue: AnyObject? - AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &roleValue) - if let role = roleValue as? String, role == "AXMenuBar" { continue } - allTexts.append(contentsOf: collectTexts(from: child, maxDepth: 10)) - } + guard let pid = observedPID else { return } + let fingerprint = notificationWindowFingerprint(for: pid) + let gateRequestedScan = scanGate.shouldScan(fingerprint: fingerprint, now: now) + if force || gateRequestedScan { + performNotificationScan() + } + } + + private func refreshNotificationCenterConnection() { + removeAXObserver() + observedPID = NSRunningApplication + .runningApplications(withBundleIdentifier: notificationCenterBundleID) + .first? + .processIdentifier + + guard let pid = observedPID else { return } + + let application = AXUIElementCreateApplication(pid) + _ = AXUIElementSetMessagingTimeout(application, axMessagingTimeout) + // Retain the application element even if this macOS version does not + // support the observer notifications; the window-change timer then acts + // as the compatibility fallback. + observedApplication = application + + var newObserver: AXObserver? + guard AXObserverCreate(pid, notificationObserverCallback, &newObserver) == .success, + let observer = newObserver else { return } + + let refcon = Unmanaged.passUnretained(self).toOpaque() + let notifications = [ + kAXCreatedNotification as CFString, + kAXWindowCreatedNotification as CFString, + ] + var registeredNotifications = 0 + for notification in notifications { + let result = AXObserverAddNotification(observer, application, notification, refcon) + if result == .success || result == .notificationAlreadyRegistered { + registeredNotifications += 1 } } - return allTexts + guard registeredNotifications > 0 else { return } + + axObserver = observer + CFRunLoopAddSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) } - func start() { - // 1.5s strikes a balance between responsiveness (banners stay on - // screen ~5s) and battery — every poll walks a chunk of the AX tree. - pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in - self?.pollNotifications() + private func removeAXObserver() { + if let observer = axObserver { + CFRunLoopRemoveSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) } + axObserver = nil + observedApplication = nil } - private func pollNotifications() { - // Throttled re-check of AX permission. If the user revokes it mid-run, - // shut down the timer and let AppState surface the permission CTA. - if Date().timeIntervalSince(lastPermissionCheck) >= permissionCheckInterval { - lastPermissionCheck = Date() - if !AXIsProcessTrusted() { - stop() - onAXPermissionLost?() - return - } + private func notificationWindowFingerprint(for pid: pid_t) -> NotificationWindowFingerprint { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let windows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return NotificationWindowFingerprint(values: []) } - let texts = getNotificationTexts() + let values = windows.compactMap { info -> String? in + guard let ownerPID = info[kCGWindowOwnerPID as String] as? NSNumber, + ownerPID.int32Value == pid, + let windowNumber = info[kCGWindowNumber as String] as? NSNumber else { + return nil + } + + let layer = (info[kCGWindowLayer as String] as? NSNumber)?.intValue ?? 0 + let alpha = (info[kCGWindowAlpha as String] as? NSNumber)?.doubleValue ?? 0 + let boundsDescription: String + if let boundsDictionary = info[kCGWindowBounds as String] as? [String: Any], + let bounds = CGRect(dictionaryRepresentation: boundsDictionary as CFDictionary) { + boundsDescription = "\(Int(bounds.minX)),\(Int(bounds.minY)),\(Int(bounds.width)),\(Int(bounds.height))" + } else { + boundsDescription = "" + } + return "\(windowNumber.intValue):\(layer):\(alpha):\(boundsDescription)" + }.sorted() + + return NotificationWindowFingerprint(values: values) + } + + private func performNotificationScan() { + guard let application = observedApplication else { return } + let texts = getNotificationTexts(from: application) guard !texts.isEmpty else { return } let combined = texts.joined(separator: " | ") @@ -131,8 +316,80 @@ class NotificationWatcher { } } - func stop() { - pollTimer?.invalidate() - pollTimer = nil + /// Reads the six attributes needed for extraction in one cross-process IPC + /// call per element and caps traversal work so a malformed or very large AX + /// tree cannot monopolize the worker indefinitely. + private func collectTexts(from root: AXUIElement, maxDepth: Int) -> [String] { + let attributes: [CFString] = [ + kAXValueAttribute as CFString, + kAXTitleAttribute as CFString, + kAXDescriptionAttribute as CFString, + kAXHelpAttribute as CFString, + kAXRoleDescriptionAttribute as CFString, + kAXChildrenAttribute as CFString, + ] + + var result: [String] = [] + var queue: [(AXUIElement, Int)] = [(root, 0)] + var index = 0 + var visited = Set() + + while index < queue.count && visited.count < maxElementsPerScan { + let (element, depth) = queue[index] + index += 1 + guard depth < maxDepth else { continue } + + let identity = CFHash(element) + guard visited.insert(identity).inserted else { continue } + + var copiedValues: CFArray? + let copyResult = AXUIElementCopyMultipleAttributeValues( + element, + attributes as CFArray, + [], + &copiedValues + ) + guard copyResult == .success, let values = copiedValues as? [Any] else { continue } + + for value in values.prefix(5) { + if let text = value as? String, !text.isEmpty { + result.append(text) + } + } + + if values.count > 5, let children = values[5] as? [AXUIElement] { + queue.append(contentsOf: children.map { ($0, depth + 1) }) + } + } + + return result + } + + private func children(of element: AXUIElement, attribute: CFString) -> [AXUIElement] { + var value: AnyObject? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { return [] } + return value as? [AXUIElement] ?? [] + } + + private func getNotificationTexts(from application: AXUIElement) -> [String] { + var allTexts: [String] = [] + + // Banners are windows on most macOS releases. Scan windows first so the + // node budget is spent on the most likely notification content. + for window in children(of: application, attribute: kAXWindowsAttribute as CFString) { + allTexts.append(contentsOf: collectTexts(from: window, maxDepth: maxTreeDepth)) + } + + // On newer releases banners may instead appear as direct children. + if allTexts.isEmpty { + for child in children(of: application, attribute: kAXChildrenAttribute as CFString) { + var roleValue: AnyObject? + _ = AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &roleValue) + if let role = roleValue as? String, role == "AXMenuBar" { continue } + allTexts.append(contentsOf: collectTexts(from: child, maxDepth: min(maxTreeDepth, 10))) + } + } + + return allTexts } } diff --git a/Tests/OTifierLibTests/OTPExtractorTests.swift b/Tests/OTifierLibTests/OTPExtractorTests.swift index 719fe5b..02831b9 100644 --- a/Tests/OTifierLibTests/OTPExtractorTests.swift +++ b/Tests/OTifierLibTests/OTPExtractorTests.swift @@ -5,6 +5,15 @@ import Foundation var passed = 0 var failed = 0 +func assertTrue(_ condition: @autoclosure () -> Bool, _ message: String, file: String = #file, line: Int = #line) { + if condition() { + passed += 1 + } else { + failed += 1 + print("FAIL (\(file):\(line)): \(message)") + } +} + func assertEqual(_ actual: String?, _ expected: String?, file: String = #file, line: Int = #line) { if actual == expected { passed += 1 @@ -79,6 +88,20 @@ func assertNil(_ actual: String?, file: String = #file, line: Int = #line) { // --- Should NOT extract: edge cases around \b --- assertNil(extractOTP(from: "decode 12345 binary string")) // 'code' is inside 'decode' + // --- Notification scan throttling --- + var gate = NotificationScanGate() + let empty = NotificationWindowFingerprint(values: []) + let banner = NotificationWindowFingerprint(values: ["42:0:1.0:0,0,400,100"]) + let start = Date(timeIntervalSince1970: 1_000) + + assertTrue(gate.shouldScan(fingerprint: empty, now: start), "initial state must be scanned") + assertTrue(gate.shouldScan(fingerprint: empty, now: start.addingTimeInterval(1)), "initial scan gets one content follow-up") + assertTrue(!gate.shouldScan(fingerprint: empty, now: start.addingTimeInterval(2)), "unchanged idle state must not scan continuously") + assertTrue(gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(3)), "a new banner window must trigger a scan") + assertTrue(gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(4)), "a new banner gets one content follow-up") + assertTrue(!gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(5)), "stable banner must not trigger repeated scans") + assertTrue(gate.shouldScan(fingerprint: banner, now: start.addingTimeInterval(35)), "the safety interval must eventually rescan") + // --- Summary --- print("\nOTP Extractor Tests: \(passed) passed, \(failed) failed") if failed > 0 {