Observe for new System Notifications OSX
Asked Answered
P

3

12

Is it possible to listen/observe for new notifications macOS receives?

I mean like when a new iMessage or a Slack message is received (so basically everything that causes NotificationCenter to display a Notification)

Petiolate answered 9/8, 2017 at 14:28 Comment(1)
Did you ever find a way to monitor them? I've tried monitoring the Notification Center Database for file changes but that doesn't work. sqlite-wal modifications don't trigger FSevents for apps like fswatch :( Running a script every X seconds to check for new notifications works, but that doesn't seem very efficientDetta
F
4

Short answer: It is not possible.

You can't observe user notifications sent by applications unless an application provides a specific API. For example the AppleScript dictionary of iMessage and Mail contains events scripts can respond to. However user notifications are encapsulated in the target application.


There is a global notification class named DistributedNotificationCenter, a notification dispatch mechanism that enables the broadcast of notifications across task boundaries. Some processes are sending distributed notifications but it's a completely different functionality as UserNotification. For example the TimeMachine engine process backupd sends distributed notifications while running a backup.

You can subscribe for all distributed notifications with

DistributedNotificationCenter.default().addObserver(self, selector: #selector(handleNotifications(_:)), name: nil, object: nil)

but I doubt that iMessage sends a distributed notification when a message is received.

Fca answered 12/8, 2017 at 17:43 Comment(1)
Note that ever since macOS Catalina, NSDistributedNotificationCenter no longer supports nil names. I was unable to get it working as a privileged operation either, but you can discover notification names by running strings against system application binaries.Java
J
1

After searching extensively online I was able to find a workaround using AXUIElement. Here's a working example with Hammerspoon (which has an excellent module available for it), but it is possible to get the same thing working with Swift or Objective-C, although a bit cumbersome since it's a low-level C API. It may be possible to use AppleScript or JXA as well.

local log = hs.logger.new("notificationcenter")
local notificationCenterBundleID = "com.apple.notificationcenterui"
local notificationCenter = hs.axuielement.applicationElement(notificationCenterBundleID)
assert(notificationCenter, "Unable to find Notification Center AX element")

local processedNotificationIDs = {}
local notificationSubroles = {
  AXNotificationCenterAlert = true,
  AXNotificationCenterBanner = true,
}
notificationObserver = hs.axuielement.observer
  .new(notificationCenter:pid())
  :callback(function(_, element)
    -- Ignore events when system drawer is open to avoid callbacks for
    -- previous notifications.
    if notificationCenter:asHSApplication():focusedWindow() then return end

    -- Process each notification only once.
    if
      not notificationSubroles[element.AXSubrole]
      or processedNotificationIDs[element.AXIdentifier]
    then
      return
    end

    -- Only match Messages and Slack notifications.
    local stackingID = element.AXStackingIdentifier
    if
      stackingID ~= "com.tinyspeck.slackmacgap"
      and stackingID:find("com.apple.MobileSMSiMessage;", 1, true) ~= 1
      and stackingID:find("com.apple.MobileSMSSMS;", 1, true) ~= 1
    then
      log.df("Skipping notification with stacking ID %s", stackingID)
      return
    end

    processedNotificationIDs[element.AXIdentifier] = true
    local staticTexts = hs.fnutils.imap(
      hs.fnutils.ifilter(element, function(value)
        return value.AXRole == "AXStaticText"
      end),
      function(value)
        return value.AXValue
      end
    )

    local title = nil
    local subtitle = nil
    local message = nil
    if #staticTexts == 2 then
      title, message = table.unpack(staticTexts)
    elseif #staticTexts == 3 then
      title, subtitle, message = table.unpack(staticTexts)
    else
      error(string.format("Unexpected static text count: %d", #staticTexts))
    end
    log.f(
      "Got notification: title = %s, subtitle = %s, message = %s",
      title,
      subtitle,
      message
    )
  end)
  :addWatcher(
    notificationCenter,

    -- Equivalent to kAXLayoutChangedNotification ("AXLayoutChanged")
    -- https://github.com/Hammerspoon/hammerspoon/blob/8ea7d105ab27c917703a6c30e5980b82a23c6a0c/extensions/axuielement/observer.m#L402
    hs.axuielement.observer.notifications["layoutChanged"]
  )
  :start()

The notification observed is AXLayoutChanged, which is less than ideal since it's invoked whenever the notification is moused over or the drawer moves, and may include events for other elements. I tried several others, the closest fit was AXCreated but that doesn't seem to get reliably invoked for new notifications. As a workaround the above code only looks at notifications received when the Notification Center drawer is closed, and uses the AXIdentifier attribute to process each notification only once.

Alternatives

This is the only working method I could find. I tried using NSDistributedNotificationCenter mentioned in @vadian's answer with names generated from strings run against system binaries such as /System/Applications/Messages.app, /System/Library/CoreServices/NotificationCenter.app and /System/Library/CoreServices/UserNotificationCenter.app, but was unable to find any notifications used for Messages or Notification Center. It's possible this was due to not finding the right value, since NSDistributedNotificationCenter no longer supports nil names. Here's the Swift script I used to check:

import Foundation

func readNames() throws -> [String] {
  let fileURL = URL(fileURLWithPath: "/path/to/notification-names.txt")
  return try String(contentsOf: fileURL, encoding: .utf8).components(separatedBy: "\n")
}

let nc = DistributedNotificationCenter.default()
let names = try readNames()
print(names)

for name in names {
    nc.addObserver(forName: NSNotification.Name(name), object: nil, queue: nil) { notification in
        print(notification)
    }
}

print("Running")
RunLoop.main.run()

notification-names.txt can be generated by e.g. strings /System/Library/CoreServices/NotificationCenter.app/Contents/MacOS/NotificationCenter | grep -F com.apple.

There is also a database file which can be queried and watched for changes (see previous threads here and here), but in the limited testing I did it was not written to in real-time, unlike the above method with AXUIElement.

Java answered 6/6, 2023 at 21:22 Comment(1)
I am also looking to observe push notification arrival. But I am looking for a stable solution for an application to know the arrival of notification. It is like, we want to observe the event that displays the notification on screen not with the presence of notification window. I also searched through some apple documentation but look like no stable solutions available. Any idea?Guardhouse
W
0

@user12638282's provided an excellent solution.

Unfortunately, it seems to be unreliable, in my testing (though I would be happy to be proved wrong). Sometimes the callback is called for new Notifications reliable and consistently, yet sometimes it stops working, without explanation.

He also mentioned that the hammerspoon event for "created" was less reliable. However, in my experience, this event is MORE reliable. However, I have yet to figure out how to get the Notification title and message from this callback.

Hopefully this helps someone along on their search. These methods both warrant further investigation.

Wafd answered 28/8 at 22:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.