How create local notification on MacOS Catalina pyobjc? [duplicate]
Asked Answered
L

1

2

I am having some difficulty finding out how to send local notifications on Catalina using pyobjc.

The closes example I have seen is this: PyObjC "Notifications are not allowed for this application"

Lineate answered 6/6, 2020 at 15:37 Comment(2)
Anyone coming here, just see the example posted above - it actually works if you use a signed version of Python (such as one downloaded from python.org)Zoila
Should be merged with: PyObjC "Notifications are not allowed for this application"Tormoria
S
4

Edit (June 27, 2020): I've created a package which has functionality to display notifications on Mac OS here. It will use PyObjC to create and display notifications. If It does not work for whatever reason, it will fallback to AppleScript notifications with osascript. I did some testing and found that the PyObjC notifications work on some devices but don't on some.

Answer:

I have also been searching for this answer, so I'd like to share what I've found:

The first thing you'll notice is that the function notify() defines a class, then returns an instance of it. You might be wondering why you can't directly call Notification.send(params). I tried it, but I was getting an error with the PyObjC, which I am unfortunately unable to fix:

# Error
class Notification(NSObject):
objc.BadPrototypeError: Objective-C expects 1 arguments, Python argument has 2 arguments for <unbound selector send of Notification at 0x10e410180>

Now onto the code:

# vscode may show the error: "No name '...' in module 'Foundation'; you can ignore it"
from Foundation import NSUserNotification, NSUserNotificationCenter, NSObject, NSDate
from PyObjCTools import AppHelper


def notify(
        title='Notification',
        subtitle=None, text=None,
        delay=0,

        action_button_title=None,
        action_button_callback=None,

        other_button_title=None,
        other_button_callback=None,

        reply_placeholder=None,
        reply_callback=None
):

  class Notification(NSObject):
    def send(self):
      notif = NSUserNotification.alloc().init()

      if title is not None:
        notif.setTitle_(title)
      if subtitle is not None:
        notif.setSubtitle_(subtitle)
      if text is not None:
        notif.setInformativeText_(text)

      # notification buttons (main action button and other button)
      if action_button_title:
        notif.setActionButtonTitle_(action_button_title)
        notif.set_showsButtons_(True)

      if other_button_title:
        notif.setOtherButtonTitle_(other_button_title)
        notif.set_showsButtons_(True)

      # reply button
      if reply_callback:
        notif.setHasReplyButton_(True)
        if reply_placeholder:
          notif.setResponsePlaceholder_(reply_placeholder)

      NSUserNotificationCenter.defaultUserNotificationCenter().setDelegate_(self)

      # setting delivery date as current date + delay (in seconds)
      notif.setDeliveryDate_(NSDate.dateWithTimeInterval_sinceDate_(delay, NSDate.date()))

      # schedule the notification send
      NSUserNotificationCenter.defaultUserNotificationCenter().scheduleNotification_(notif)

      # on if any of the callbacks are provided, start the event loop (this will keep the program from stopping)
      if action_button_callback or other_button_callback or reply_callback:
        print('started')
        AppHelper.runConsoleEventLoop()

    def userNotificationCenter_didDeliverNotification_(self, center, notif):
      print('delivered notification')

    def userNotificationCenter_didActivateNotification_(self, center, notif):
      print('did activate')
      response = notif.response()

      if notif.activationType() == 1:
        # user clicked on the notification (not on a button)
        # don't stop event loop because the other buttons can still be pressed
        pass

      elif notif.activationType() == 2:
        # user clicked on the action button
        action_button_callback()
        AppHelper.stopEventLoop()

      elif notif.activationType() == 3:
        # user clicked on the reply button
        reply_text = response.string()
        reply_callback(reply_text)
        AppHelper.stopEventLoop()

  # create the new notification
  new_notif = Notification.alloc().init()

  # return notification
  return new_notif


def main():
  n = notify(
      title='Notification',
      delay=0,
      action_button_title='Action',
      action_button_callback=lambda: print('Action'),
      # other_button_title='Other',
      # other_button_callback=lambda: print('Other'),

      reply_placeholder='Enter your reply please',
      reply_callback=lambda reply: print('Replied: ', reply),
  )
  n.send()


if __name__ == '__main__':
  main()

Explanation

The notify() function takes in quite a few parameters (they are self-explanatory). The delay is how many seconds later the notification will appear. Note that if you set a delay that's longer than the execution of the program, the notification will be sent ever after the program is being executed.

You'll see the button parameters. There are three types of buttons:

  1. Action button: the dominant action
  2. Other button: the secondary action
  3. Reply button: the button that opens a text field and takes a user input. This is commonly seen in messaging apps like iMessage.

All those if statements are setting the buttons appropriately and self explanatory. For instance, if the parameters for the other button are not provided, a Other button will not be shown.

One thing you'll notice is that if there are buttons, we are starting the console event loop:

      if action_button_callback or other_button_callback or reply_callback:
        print('started')
        AppHelper.runConsoleEventLoop()

This is a part of Python Objective-C. This is not a good explanation, but it basically keeps program "on" (I hope someone cane give a better explanation).

Basically, if you specify that you want a button, the program will continue to be "on" until AppHelper.stopEventLoop() (more about this later).

Now there are some "hook" functions:

  1. userNotificationCenter_didDeliverNotification_(self, notification_center, notification): called when the notification is delivered
  2. userNotificationCenter_didActivateNotification_(self, notification_center, notification): called when the user interacts with the notification (clicks, clicks action button, or reply) (documentation)

There surely are more, but I do not think there is a hook for the notification being dismissed or ignored, unfortunately.

With userNotificationCenter_didActivateNotification_, we can define some callbacks:


    def userNotificationCenter_didActivateNotification_(self, center, notif):
      print('did activate')
      response = notif.response()

      if notif.activationType() == 1:
        # user clicked on the notification (not on a button)
        # don't stop event loop because the other buttons can still be pressed
        pass

      elif notif.activationType() == 2:
        # user clicked on the action button

        # action button callback
        action_button_callback()
        AppHelper.stopEventLoop()

      elif notif.activationType() == 3:
        # user clicked on the reply button
        reply_text = response.string()

        # reply button callback
        reply_callback(reply_text)
        AppHelper.stopEventLoop()

There are different activation types for the types of actions. The text from the reply action can also be retrieved as shown.

You'll also notice the AppHelper.stopEventLoop() at the end. This means to "end" the program from executing, since the notification has been dealt with by the user.

Now let's address all the problems with this solution.

Problems

  1. The program will never stop if the user does not interact with the notification. The notification will slide away into the notification center and may or may never be interacted with. As I stated before, there's no hook for notification ignored or notification dismissed, so we cannot call AppHelper.stopEventLoop() at times like this.
  2. Because AppHelper.stopEventLoop() is being run after interaction, it is not possible to send multiple notifications with callbacks, as the program will stop executing after the first notification is interacted with.
  3. Although I can show the Other button (and give it text), I couldn't find a way to give it a callback. This is why I haven't addressed it in the above code block. I can give it text, but it's essentially a dummy button as it cannot do anything.

Should I still use this solution?

If you want notifications with callbacks, you probably should not, because of the problems I addressed.

If you only want to show notifications to alert the user on something, yes.

Other solutions

PYNC is a wrapper around terminal-notifier. However, both received their last commit in 2018. Alerter seems to be a successor to terminal-notifier, but there is not Python wrapper.

You can also try running applescript to send notifications, but you cannot set callbacks, nor can you change the icon.

I hope this answer has helped you. I am also trying to find out how to reliably send notifications with callbacks on Mac OS. I've figured out how to send notifications, but callbacks is the issue.

Sharice answered 7/6, 2020 at 16:20 Comment(13)
I am also looking for a solution using for rubicon-objc and I think it might fix some of your problems if you come, please share it here.Lineate
Also are you able to share the project that is using this code just for me to see??Lineate
Sorry, I don't have a project using this code, as I was only trying it for testing purposes. Thanks for telling me about Rubicon objc. If you manage to find a minimal working example of sending notifications with it, please notify me. I will also try using it soonSharice
I tried to run the code and it would only output 'started' and no notification would pop out, I am using python 3.7.7 and all of pyobjc is version 6.2Lineate
@Lineate Check your mac os notification settings. Notifications should be set to either banners or alerts. Sometimes the notification takes a few seconds to deliver tooSharice
@anthem I was checking and found out why it doesn't work on Catalina NSUserNotification was Deprecated on macOS 10.14 (Mojave)Lineate
Hmm, that is weird because I ran all this code successfully on 10.15.3. I am currently working on a package that has notification functionality github.com/ninest/notipy_osx Also, do you r notifications settings allow it?Sharice
yes my settings do allow it, do you have an implementation for UNUserNotificationCenterLineate
I'm sorry, I wasn't able to figure it out. I'm hoping someone else can also answer the question and help us bothSharice
@Lineate Sorry for sending so many comments, but are you sure you 1) Enabled notifications for Python in system preferences and 2) Made sure you have PyObjC installed? I tried the same code on a friend's computer and it was working okaySharice
Yes i just check I have it enabled and pyobjc is installed. This is my setup, Python 3.7.7 64-bit within a virtual environment and I am on macOS 10.15.5 Catalina. Can you comment with yoursLineate
Same Python version, Mac OS 10.15.3. Since this doesn't work for you, you could make a wrapper around osascript. You can see how I've done it in my library here. It isn't as customizable (no custom icon, no delay), but it should definitely work since it's using AppleScriptSharice
That would be a perfect solution but when I running I am not allowed to use the terminalLineate

© 2022 - 2024 — McMap. All rights reserved.