Skip to content

Instantly share code, notes, and snippets.

@orestesgaolin
Created August 28, 2025 19:47
Show Gist options
  • Select an option

  • Save orestesgaolin/44b2a4f962f30b3c04e03f4ffe2614ff to your computer and use it in GitHub Desktop.

Select an option

Save orestesgaolin/44b2a4f962f30b3c04e03f4ffe2614ff to your computer and use it in GitHub Desktop.
import Cocoa
@preconcurrency import ApplicationServices
import Foundation
import PlaygroundSupport
// Keep the playground running to listen for notifications
PlaygroundPage.current.needsIndefiniteExecution = true
@MainActor
final class NotificationSpeaker {
private var observer: AXObserver?
private var appElement: AXUIElement?
init?() {
// Ask for Accessibility
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
_ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
guard let centerApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.notificationcenterui").first
else { return nil }
let pid = centerApp.processIdentifier
appElement = AXUIElementCreateApplication(pid)
var obs: AXObserver?
guard AXObserverCreate(pid, { _, element, notif, refcon in
let me = Unmanaged<NotificationSpeaker>.fromOpaque(refcon!).takeUnretainedValue()
me.handle(event: notif as String, element: element)
}, &obs) == .success, let observer = obs, let appEl = appElement else { return nil }
self.observer = observer
// Watch for new notification windows
AXObserverAddNotification(observer, appEl, kAXWindowCreatedNotification as CFString,
UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
AXObserverAddNotification(observer, appEl, kAXUIElementDestroyedNotification as CFString, nil)
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
}
private func handle(event: String, element: AXUIElement) {
guard event == (kAXWindowCreatedNotification as String) else { return }
let text = collectText(from: element)
.joined(separator: " ")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
// Optional: filter/shorten
if !text.isEmpty { speakWithSay(text) }
}
// Recursively pull strings from AX tree
private func collectText(from el: AXUIElement) -> [String] {
var out: [String] = []
if let s = axCopy(el, kAXTitleAttribute as CFString) as? String, !s.isEmpty {
out.append(s)
}
if let s = axCopy(el, kAXValueAttribute as CFString) as? String, !s.isEmpty {
out.append(s)
}
if let s = axCopy(el, kAXDescriptionAttribute as CFString) as? String, !s.isEmpty {
out.append(s)
}
if let children = axCopy(el, kAXChildrenAttribute as CFString) as? [AXUIElement] {
for c in children { out.append(contentsOf: collectText(from: c)) }
}
return out
}
private func axCopy(_ el: AXUIElement, _ attr: CFString) -> CFTypeRef? {
var v: CFTypeRef?
return AXUIElementCopyAttributeValue(el, attr, &v) == .success ? v : nil
}
private func speakWithSay(_ text: String) {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/say")
p.arguments = ["-r", "220", text] // tweak voice/rate: e.g., "-v", "Ava"
try? p.run()
}
}
let watcher = NotificationSpeaker()
RunLoop.current.run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment