Skip to content

NotificationWatcher broken on macOS 26 (Tahoe) — AXObserver no longer fires for notification banners #4

Description

@LorensDomins

Problem

On macOS 26 (Tahoe, Darwin 25.x), the current NotificationWatcher.swift does not detect any notifications. The AXObserver watching kAXWindowCreatedNotification on the com.apple.notificationcenterui process never fires — Apple changed how notification banners are rendered internally, so the Accessibility event is no longer emitted.

This means system notification haptics don't work at all on macOS 26, even though the Logi Options+ Smart Action and HapticEngine are working fine.

Root Cause

AXObserverCreate + kAXWindowCreatedNotification on the NotificationCenter process relied on macOS creating an AX-visible window for each notification banner. As of macOS 26, this no longer happens.

Fix

Replace the AXObserver approach with a /usr/bin/log stream watcher. The macOS unified log reliably emits an "Adding notification to storage" message in the NotificationCenter process for every delivered notification — including native app notifications (Claude, Mail, etc.) and Safari web notifications (_WEB_CENTER_: entries like Google Chat).

Replacement NotificationWatcher.swift

import Foundation

class NotificationWatcher {
    static let shared = NotificationWatcher()
    private var process: Process?

    func start() {
        DispatchQueue.global(qos: .utility).async {
            self.startLogStream()
        }
    }

    private func startLogStream() {
        let proc = Process()
        proc.executableURL = URL(fileURLWithPath: "/usr/bin/log")
        proc.arguments = [
            "stream",
            "--predicate",
            "process == \"NotificationCenter\" AND composedMessage CONTAINS \"Adding notification to storage\""
        ]

        let pipe = Pipe()
        proc.standardOutput = pipe
        proc.standardError = FileHandle.nullDevice

        pipe.fileHandleForReading.readabilityHandler = { handle in
            let data = handle.availableData
            guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }

            for entry in line.components(separatedBy: .newlines) where entry.contains("Adding notification to storage") {
                if UserDefaults.standard.bool(forKey: "systemNotificationsEnabled") == false {
                    continue
                }
                print("Notification Detected: \(entry)")
                let pattern = UserDefaults.standard.string(forKey: "notificationPattern") ?? "pulse"
                if pattern != "none" {
                    HapticEngine.shared.playPattern(pattern)
                }
            }
        }

        do {
            try proc.run()
            self.process = proc
            print("Listening for notifications via log stream...")
            proc.waitUntilExit()
        } catch {
            print("Failed to start log stream: \(error)")
        }
    }
}

Testing

Tested on macOS 26.4.1 (Build 25E253) with MX Master 4. Confirmed via log stream that the following notification sources all produce the "Adding notification to storage" event:

  • Safari web notifications_WEB_CENTER_:web.mail.google.com (Google Chat)
  • Claude desktopcom.anthropic.claudefordesktop
  • Apple Mailcom.apple.mail

All triggered haptic feedback successfully after the fix.

Notes

  • This also removes the dependency on Cocoa and ApplicationServices imports (only Foundation is needed)
  • The AXObserver approach may still work on older macOS versions, so a version check fallback could be considered
  • The log stream approach requires no additional entitlements beyond the existing Accessibility permission

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions