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 {