Here’s the promised Swift-based command line tool. It only works in macOS Sequoia (and is likely to require updating with each major macOS release, as with GUI AppleScripts). The application running the binary requires accessibility permissions.
I’d welcome any style advice for handling the C-to-Swift interface.
It’s invoked from the shell as:
NotificationParser <performActionName> <performActionFilter>
If invoked without any arguments, it will print the notifications in the following format:
index, bundle_id, title, subtitle, message
(If subtitle
does not exist, it will be omitted along with its preceding ,
.)
For my example notification in the first post, this would output:
1, com.apple.ScriptEditor2, Script Editor, Testing 123
Note that as with the above solution by @Dirk, it’s not possible to fully disambiguate the title, subtitle, and message, as they’re pulled from one comma-separated field. This executable additionally converts any linefeeds in that field (which should be rare) to vertical tabs to prevent output from spilling into multiple lines.
The tool can additionally be called with an action (same actions as vanilla GUI AppleScript, but also accepts more natural command names), which will be performed on all notifications…
NotificationParser close
…or only on notifications that match a specified string:
NotificationParser close "Script Editor, Testing 123"
Swift Source Code
// Version 0.5
import AppKit
import ApplicationServices
// Look for an action name & action filter supplied on the command line.
let arguments = CommandLine.arguments.dropFirst()
var actionName = arguments.first?.lowercased()
switch actionName { // Handle defective action specifiers. The first string specified is Apple-defined.
case "axshowmenu", "showmenu", "menu":
actionName = "AXShowMenu"
case "axpress", "press":
actionName = "AXPress"
case "name:show details\ntarget:0x0\nselector:(null)", "show details", "showdetails", "details":
actionName = "Name:Show Details\nTarget:0x0\nSelector:(null)"
case "name:show\ntarget:0x0\nselector:(null)", "show":
actionName = "Name:Show\nTarget:0x0\nSelector:(null)"
case "name:close\ntarget:0x0\nselector:(null)", "close":
actionName = "Name:Close\nTarget:0x0\nSelector:(null)"
case "":
actionName = nil
case nil:
break
case "-h", "--help":
exit(0, withErrorMessage:"usage: NotificationParser <performActionName> <performActionFilter>\n\nReturn Format:\nIndex (1-Based), Bundle ID, Title[, Subtitle], Message")
default:
exit(4, withErrorMessage:"Unrecognized action specified")
}
var actionFilter = arguments.dropFirst().first
if actionFilter == "" { actionFilter = nil }
// Get the accessibility object for the Notification Center.
let notificationCenter = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.notificationcenterui")
guard let pid = notificationCenter.first?.processIdentifier else { exit(2, withErrorMessage:"Unable to get process ID of Notification Center") }
let appRef = AXUIElementCreateApplication(pid)
let app = UIElement(appRef)
guard let app else { exit(5, withErrorMessage:"Unable to access the Accessibility hierarchy (check the permission for the parent application in System Settings > Privacy & Security > Accessibility)") }
// Get the main Notification Center window.
let windowRefs = getAttributeValue(element: appRef, attributeName: "AXWindows") as? [AXUIElement] // kAXWindowsAttribute
if windowRefs?.count == 0 {
exit(0)
}
guard windowRefs?.count == 1 else { exit(3, withErrorMessage:"Unable to identify main Notification Center window") }
let windowRef = windowRefs![0]
let window = UIElement(windowRef)!
// Get the scroll area.
let scrollArea = window.child(1)?.child(1)?.child(1)
guard let scrollArea else { exit(3, withErrorMessage:"Unable to get Notification Center scroll area") }
// Get the button objects, containing the notifications' descriptions & actions.
var notifications = scrollArea.children
guard notifications.count > 1 else { exit(3, withErrorMessage:"Unable to get notifications (button elements)") }
notifications.removeLast() // Menu Button
// Print the descriptions.
for (i, var notification) in notifications.enumerated() {
guard let notificationText = notification.accessibilityDescription else { exit(3, withErrorMessage:"Unable to get AXDescription attribute of notification (button element)") }
// Replace any linefeeds in the title/subtitle/message with a vertical tab.
print("\(i + 1), \(notification.bundleID ?? "unknown"), \(notificationText.replacingOccurrences(of: "\n", with:"\u{0B}"))")
}
// Perform the action specified on the command line.
let filteredNotifications = notifications.filter { actionFilter == nil || $0.accessibilityDescription == actionFilter }
for notification in filteredNotifications.reversed() {
notification.performAction(actionName)
}
// Exit.
exit(0)
// WRAPPER TYPE.
struct UIElement : CustomStringConvertible {
let reference: AXUIElement
let role: String
let title: String?
let accessibilityDescription: String?
lazy var bundleID: String? = { // Use only for notification (button element)
let bid = stringValueForAttribute("AXStackingIdentifier")
guard let bid else { return nil }
return String(bid[bid.index(bid.startIndex, offsetBy: 17)...])
}()
init?(_ reference: CFTypeRef?) {
let element = reference as! AXUIElement
self.reference = element
let role = getAttributeValue(element: element, attributeName: "AXRole") as? String // kAXRoleAttribute
guard let role else { return nil } // AXRole is required for all Accessibility objects
self.role = role
self.title = getAttributeValue(element: element, attributeName: "AXTitle") as? String // kAXTitleAttribute
self.accessibilityDescription = getAttributeValue(element: element, attributeName: "AXDescription") as? String // kAXDescriptionAttribute
}
var description: String { // For CustomStringConvertible, not AXDescription
let name = title == nil ? "(Unnamed)" : "\"\(title!)\""
return "\(role) \(name)"
}
func child(_ index: Int) -> UIElement? { // 1-based, negative indexing supported.
let childrenRefs = getAttributeValue(element: reference, attributeName: "AXChildren") as? [AXUIElement] // kAXChildrenAttribute
guard let childrenRefs else { return nil }
guard index != 0 else { fatalError("UIElement.child is 1-based; not valid for an index of 0") }
let i = index < 0 ? childrenRefs.count + index + 1 : index - 1 // Convert to 0-based, positive indexing.
guard i < childrenRefs.count && i > -1 else { return nil }
return UIElement(childrenRefs[i])
}
var children: [UIElement] {
let childrenRefs = getAttributeValue(element: reference, attributeName: "AXChildren") as? [AXUIElement] // kAXChildrenAttribute
guard let childrenRefs else { return [] }
var children: [UIElement] = []
for childRef in childrenRefs {
children.append(UIElement(childRef)!)
}
return children
}
func performAction(_ actionName: String?) {
if let actionName { AXUIElementPerformAction(reference, actionName as NSString) }
}
var attributeNames: [String] {
var names: CFArray?
let err = AXUIElementCopyAttributeNames(reference, &names)
guard err == .success else { return [] }
return names as! [String]
}
func stringValueForAttribute(_ attributeName: String) -> String? {
return getAttributeValue(element: reference, attributeName: attributeName) as? String
}
}
// HELPER FUNCTIONS.
// Exit with error message.
func exit(_ code: CInt, withErrorMessage message: String) -> Never {
fputs(message, stderr)
exit(code)
}
// Get specified attribute.
func getAttributeValue(element: AXUIElement, attributeName: String) -> CFTypeRef? {
var value: CFTypeRef?
let err = AXUIElementCopyAttributeValue(element, attributeName as NSString, &value)
guard err == .success else { return nil }
return value
}
Compile with swiftc -o NotificationParser NotificationParser.swift
(Requires Xcode or the Xcode Command Line Tools.)
Execution takes about 25 ms (iMac Pro) or 13 ms (M1 Pro).
Pre-Compiled Binary (Universal, Unsigned) (46.0 KB)
Use from AppleScript:
-- Version 0.5.1
to getUserNotificationInfo()
set notifications_s to do shell script quoted form of NOTIFICATION_PARSER_PATH without altering line endings
set previous_TIDs to AppleScript's text item delimiters
set AppleScript's text item delimiters to linefeed
set notifications_L to text items of notifications_s -- Includes trailing empty string.
set notifications to {}
set AppleScript's text item delimiters to ", "
repeat with i from 1 to ((length of notifications_L) - 1)
set notification_s to item i of notifications_L
set notification_s_components to text items of notification_s
set notification_index to item 1 of notification_s_components as integer
set application_bid to item 2 of notification_s_components
try
set application_name to name of application id application_bid as string
on error
set application_name to "Unknown"
end try
set notification_title to item 3 of notification_s_components
set notification_subtitle to "" -- Unable to disambiguate from message.
set notification_message to items 4 thru -1 of notification_s_components as string
set AppleScript's text item delimiters to character id 11 -- Vertical tab (from substitution in binary)
set notification_message to text items
set AppleScript's text item delimiters to linefeed
set notification_message to notification_message as string
set the end of notifications to {notification_index:notification_index, application_bid:application_bid, application_name:application_name, notification_title:notification_title, notification_subtitle:notification_subtitle, notification_message:notification_message}
end repeat
set AppleScript's text item delimiters to AppleScript's text item delimiters as string
return notifications
end getUserNotificationInfo
Which, in the above example, returns:
{{notification_index:1, application_bid:"com.apple.ScriptEditor2", application_name:"Script Editor", notification_title:"Script Editor", notification_subtitle:"", notification_message:"Testing 123"}}
Two notes:
- Since it’s not possible to disambiguate the subtitle without further work, this handler always returns
""
as the subtitle. The subtitle, if present, will be prepended to the message field.
- This binary handles notifications from multiple applications, but does not yet handle stacked notifications (when a single application displays multiple applications). This will be possible with some more work.
Feedback is welcomed!