diff --git a/README.md b/README.md index 1bdef3f..42b590f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ if __name__ == "__main__": title="Meeting starts now!", subtitle="Team Standup", icon="/Users/jorrick/zoom.png", + sound="Frog", action_button_str="Join zoom meeting", action_callback=partial(join_zoom_meeting, conf_number=zoom_conf_number) ) @@ -63,7 +64,7 @@ A simple example. Please look [in the docs](https://jorricks.github.io/macos-not ## Why did you create this library? I wanted a library that did not depend on any non-python tools (so you had to go around and install that). Instead, I wanted a library where you install the pip packages, and you are done. -Later I realised how hard it was to integrate correctly with PyOBJC. Also, I had a hard time finding any examples on how to easily integrate this in a non-blocking fashion with my tool. +Later I realised how hard it was to integrate correctly with PyOBJC. Also, I had a hard time finding any examples on how to easily integrate this in a non-blocking fashion with my tool. Hence, I figured I should set it up to be as user-friendly as possible and share it with the world ;)! @@ -72,4 +73,4 @@ Although there are some limitations, there is no reason to not use it now :v:. - You need to keep your application running while waiting for the callback to happen. - We do not support raising notifications from anything but the main thread. If you wish to raise it from other threads, you need to set up a communication channel with the main thread, which in turn than raises the notification. - Currently, we are only supporting the old deprecated [user notifications](https://developer.apple.com/documentation/foundation/nsusernotification). If you wish to use the new implementation, please feel free to propose an MR. -- You can not change the main image of the notification to be project specific. You can only change the Python interpreter image, but that would impact all notifications send by Python. +- You can not change the main image of the notification to be project specific. You can only change the Python interpreter image, but that would impact all notifications send by Python. diff --git a/docs/index.md b/docs/index.md index 8e96f39..257e722 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ if __name__ == "__main__": title="Meeting starts now!", subtitle="Team Standup", icon="/Users/jorrick/zoom.png", + sound="Frog", action_button_str="Join zoom meeting", action_callback=partial(join_zoom_meeting, conf_number=zoom_conf_number) ) diff --git a/src/mac_notifications/client.py b/src/mac_notifications/client.py index 0f9fb7a..673ef69 100644 --- a/src/mac_notifications/client.py +++ b/src/mac_notifications/client.py @@ -30,6 +30,7 @@ def create_notification( subtitle: str | None = None, text: str | None = None, icon: str | Path | None = None, + sound: str | None = None, delay: timedelta = timedelta(), action_button_str: str | None = None, action_callback: Callable[[], None] | None = None, @@ -56,6 +57,7 @@ def create_notification( subtitle=subtitle, text=text, icon=(str(icon.resolve()) if isinstance(icon, Path) else icon) if icon else None, + sound=sound, delay=delay, action_button_str=action_button_str, action_callback=action_callback, diff --git a/src/mac_notifications/listener_process.py b/src/mac_notifications/listener_process.py deleted file mode 100644 index 2159737..0000000 --- a/src/mac_notifications/listener_process.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from multiprocessing import Process, SimpleQueue - -from mac_notifications import notification_sender -from mac_notifications.notification_config import JSONNotificationConfig - - -class NotificationProcess(Process): - """ - This is a simple process to launch a notification in a separate process. - - Why you may ask? - First, the way we need to launch a notification using a class, this class can only be instantiated once in a - process. Hence, for simple notifications we create a new process and then immediately stop it after the notification - was launched. - Second, waiting for the user interaction with a notification is a blocking operation. - Because it is a blocking operation, if we want to be able to receive any user interaction from the notification, - without completely halting/freezing our main process, we need to open it in a background process. - """ - - def __init__(self, notification_config: JSONNotificationConfig, queue: SimpleQueue | None): - super().__init__() - self.notification_config = notification_config - self.queue = queue - - def run(self) -> None: - notification_sender.create_notification(self.notification_config, self.queue).send() - # on if any of the callbacks are provided, start the event loop (this will keep the program from stopping) diff --git a/src/mac_notifications/manager.py b/src/mac_notifications/manager.py index 5abe003..55ade10 100644 --- a/src/mac_notifications/manager.py +++ b/src/mac_notifications/manager.py @@ -9,10 +9,10 @@ from threading import Event, Thread from typing import Dict, List -from mac_notifications.listener_process import NotificationProcess from mac_notifications.notification_config import NotificationConfig +from mac_notifications.notification_sender import cancel_notification, create_notification from mac_notifications.singleton import Singleton -from mac_notifications.notification_sender import cancel_notification + """ This is the module responsible for managing the notifications over time & enabling callbacks to be executed. @@ -48,13 +48,14 @@ class NotificationManager(metaclass=Singleton): """ def __init__(self): - self._callback_queue: SimpleQueue = SimpleQueue() + self._callback_queue: SimpleQueue | None = None self._callback_executor_event: Event = Event() self._callback_executor_thread: CallbackExecutorThread | None = None - self._callback_listener_process: NotificationProcess | None = None # Specify that once we stop our application, self.cleanup should run atexit.register(self.cleanup) + # Specify that when we get a keyboard interrupt, this function should handle it + self.original_sigint_handler = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, handler=self.catch_keyboard_interrupt) def create_callback_executor_thread(self) -> None: @@ -73,20 +74,15 @@ def create_notification(self, notification_config: NotificationConfig) -> Notifi :param notification_config: The configuration for the notification. """ json_config = notification_config.to_json_notification() - if not notification_config.contains_callback or self._callback_listener_process is not None: - # We can send it directly and kill the process after as we don't need to listen for callbacks. - new_process = NotificationProcess(json_config, None) - new_process.start() - new_process.join(timeout=5) - else: - # We need to also start a listener, so we send the json through a separate process. - self._callback_listener_process = NotificationProcess(json_config, self._callback_queue) - self._callback_listener_process.start() - self.create_callback_executor_thread() if notification_config.contains_callback: + # We need to also start a listener, so we send the json through a separate process. + self._callback_queue = self._callback_queue or SimpleQueue() + self.create_callback_executor_thread() _FIFO_LIST.append(notification_config.uid) _NOTIFICATION_MAP[notification_config.uid] = notification_config + + create_notification(json_config, None) self.clear_old_notifications() return Notification(notification_config.uid) @@ -108,24 +104,22 @@ def get_active_running_notifications() -> int: def catch_keyboard_interrupt(self, *args) -> None: """We catch the keyboard interrupt but also pass it onto the user program.""" self.cleanup() - sys.exit(signal.SIGINT) + signal.signal(signal.SIGINT, handler=self.original_sigint_handler) + signal.raise_signal(signal.SIGINT) def cleanup(self) -> None: """Stop all processes related to the Notification callback handling.""" if self._callback_executor_thread: self._callback_executor_event.clear() self._callback_executor_thread.join() - if self._callback_listener_process: - self._callback_listener_process.kill() self._callback_executor_thread = None - self._callback_listener_process = None _NOTIFICATION_MAP.clear() _FIFO_LIST.clear() class CallbackExecutorThread(Thread): """ - Background threat that checks each 0.1 second whether there are any callbacks that it should execute. + Background thread that checks each 0.1 second whether there are any callbacks that it should execute. """ def __init__(self, keep_running: Event, callback_queue: SimpleQueue): diff --git a/src/mac_notifications/notification_config.py b/src/mac_notifications/notification_config.py index dbc78e9..9574d54 100644 --- a/src/mac_notifications/notification_config.py +++ b/src/mac_notifications/notification_config.py @@ -20,6 +20,7 @@ class NotificationConfig: subtitle: str | None text: str | None icon: str | None + sound: str | None delay: timedelta action_button_str: str | None action_callback: Callable[[], None] | None @@ -42,6 +43,7 @@ def to_json_notification(self) -> "JSONNotificationConfig": subtitle=NotificationConfig.c_compliant(self.subtitle), text=NotificationConfig.c_compliant(self.text), icon=self.icon, + sound=self.sound, delay_in_seconds=(self.delay or timedelta()).total_seconds(), action_button_str=NotificationConfig.c_compliant(self.action_button_str), action_callback_present=bool(self.action_callback), @@ -70,6 +72,7 @@ class JSONNotificationConfig: subtitle: str | None text: str | None icon: str | None + sound: str | None delay_in_seconds: float action_button_str: str | None action_callback_present: bool diff --git a/src/mac_notifications/notification_sender.py b/src/mac_notifications/notification_sender.py index f4a045b..dd12dae 100644 --- a/src/mac_notifications/notification_sender.py +++ b/src/mac_notifications/notification_sender.py @@ -1,114 +1,143 @@ +""" +This module is responsible for creating the notifications in the C-layer and listening/reporting about user activity. +""" from __future__ import annotations import logging +import re from multiprocessing import SimpleQueue -from typing import Any +from typing import Any, Type +import ctypes from AppKit import NSImage from Foundation import NSDate, NSObject, NSURL, NSUserNotification, NSUserNotificationCenter +from objc import python_method from PyObjCTools import AppHelper from mac_notifications.notification_config import JSONNotificationConfig logger = logging.getLogger() -""" -This module is responsible for creating the notifications in the C-layer and listening/reporting about user activity. -""" -def create_notification(config: JSONNotificationConfig, queue_to_submit_events_to: SimpleQueue | None) -> Any: +def create_notification(config: JSONNotificationConfig, queue_to_submit_events_to: SimpleQueue | None) -> None: """ - Create a notification and possibly listed & report about notification activity. + Create a notification and possibly listen & report about notification activity. :param config: The configuration of the notification to send. :param queue_to_submit_events_to: The Queue to submit user activity related to the callbacks to. If this argument is passed, it will start the event listener after it created the Notifications. If this is None, it will only create the notification. """ - - class MacOSNotification(NSObject): - def send(self): - """Sending of the notification""" - notification = NSUserNotification.alloc().init() - notification.setIdentifier_(config.uid) - if config is not None: - notification.setTitle_(config.title) - if config.subtitle is not None: - notification.setSubtitle_(config.subtitle) - if config.text is not None: - notification.setInformativeText_(config.text) - if config.icon is not None: - url = NSURL.alloc().initWithString_(f"file://{config.icon}") - image = NSImage.alloc().initWithContentsOfURL_(url) - notification.setContentImage_(image) - - # Notification buttons (main action button and other button) - if config.action_button_str: - notification.setActionButtonTitle_(config.action_button_str) - notification.setHasActionButton_(True) - - if config.snooze_button_str: - notification.setOtherButtonTitle_(config.snooze_button_str) - - if config.reply_callback_present: - notification.setHasReplyButton_(True) - if config.reply_button_str: - notification.setResponsePlaceholder_(config.reply_button_str) - - NSUserNotificationCenter.defaultUserNotificationCenter().setDelegate_(self) - - # Setting delivery date as current date + delay (in seconds) - notification.setDeliveryDate_( - NSDate.dateWithTimeInterval_sinceDate_(config.delay_in_seconds, NSDate.date()) - ) - - # Schedule the notification send - NSUserNotificationCenter.defaultUserNotificationCenter().scheduleNotification_(notification) - - # Wait for the notification CallBack to happen. - if queue_to_submit_events_to: - logger.debug("Started listening for user interactions with notifications.") - AppHelper.runConsoleEventLoop() - - def userNotificationCenter_didDeliverNotification_( - self, center: "_NSConcreteUserNotificationCenter", notif: "_NSConcreteUserNotification" # type: ignore # noqa - ) -> None: - """Respond to the delivering of the notification.""" - logger.debug(f"Delivered: {notif.identifier()}") - - def userNotificationCenter_didActivateNotification_( - self, center: "_NSConcreteUserNotificationCenter", notif: "_NSConcreteUserNotification" # type: ignore # noqa - ) -> None: - """ - Respond to a user interaction with the notification. - """ - identifier = notif.identifier() - response = notif.response() - activation_type = notif.activationType() - - if queue_to_submit_events_to is None: - raise ValueError("Queue should not be None here.") - else: - queue: SimpleQueue = queue_to_submit_events_to - - logger.debug(f"User interacted with {identifier} with activationType {activation_type}.") - if activation_type == 1: - # user clicked on the notification (not on a button) - pass - - elif activation_type == 2: # user clicked on the action button - queue.put((identifier, "action_button_clicked", "")) - - elif activation_type == 3: # User clicked on the reply button - queue.put((identifier, "reply_button_clicked", response.string())) - - # create the new notification - new_notif = MacOSNotification.alloc().init() - - # return notification - return new_notif + notification = _build_notification(config) + macos_notification = MacOSNotification.alloc().init() + macos_notification.send(notification, config, queue_to_submit_events_to) + + +class MacOSNotification(NSObject): + @python_method + def send( + self, + notification: NSUserNotification, + config: JSONNotificationConfig, + queue_to_submit_events_to: SimpleQueue | None + ): + """Sending of the notification""" + self.queue_to_submit_events_to = queue_to_submit_events_to + NSUserNotificationCenter.defaultUserNotificationCenter().setDelegate_(self) + + # Setting delivery date as current date + delay (in seconds) + notification.setDeliveryDate_( + NSDate.dateWithTimeInterval_sinceDate_(config.delay_in_seconds, NSDate.date()) + ) + + # Schedule the notification send + NSUserNotificationCenter.defaultUserNotificationCenter().scheduleNotification_(notification) + + # Wait for the notification CallBack to happen. + if queue_to_submit_events_to: + logger.debug("Started listening for user interactions with notifications.") + AppHelper.runConsoleEventLoop() + + def userNotificationCenter_didDeliverNotification_( + self, + center: "_NSConcreteUserNotificationCenter", + notif: "_NSConcreteUserNotification" + ) -> None: + """Respond to the delivering of the notification.""" + logger.debug(f"Delivered: {notif.identifier()}") + + def userNotificationCenter_didActivateNotification_( + self, + center: "_NSConcreteUserNotificationCenter", + notif: "_NSConcreteUserNotification" # type: ignore # noqa + ) -> None: + """ + Respond to a user interaction with the notification. + """ + identifier = notif.identifier() + response = notif.response() + activation_type = notif.activationType() + + if self.queue_to_submit_events_to is None: + raise ValueError("Queue should not be None here.") + else: + queue: SimpleQueue = self.queue_to_submit_events_to + + logger.debug(f"User interacted with {identifier} with activationType {activation_type}.") + if activation_type == 1: + # user clicked on the notification (not on a button) + pass + + elif activation_type == 2: # user clicked on the action button + queue.put((identifier, "action_button_clicked", "")) + + elif activation_type == 3: # User clicked on the reply button + queue.put((identifier, "reply_button_clicked", response.string())) def cancel_notification(uid:str) -> None: notification = NSUserNotification.alloc().init() notification.setIdentifier_(uid) NSUserNotificationCenter.defaultUserNotificationCenter().removeDeliveredNotification_(notification) + + +def _build_notification(config: JSONNotificationConfig) -> NSUserNotification: + notification = NSUserNotification.alloc().init() + notification.setIdentifier_(config.uid) + if config is not None: + notification.setTitle_(config.title) + if config.subtitle is not None: + notification.setSubtitle_(config.subtitle) + if config.text is not None: + notification.setInformativeText_(config.text) + if config.sound is not None: + notification.setSoundName_(config.sound) + if config.icon is not None: + url = NSURL.alloc().initWithString_(f"file://{config.icon}") + image = NSImage.alloc().initWithContentsOfURL_(url) + notification.setContentImage_(image) + + # Notification buttons (main action button and other button) + if config.action_button_str: + notification.setActionButtonTitle_(config.action_button_str) + notification.setHasActionButton_(True) + + if config.snooze_button_str: + notification.setOtherButtonTitle_(config.snooze_button_str) + + if config.reply_callback_present: + notification.setHasReplyButton_(True) + if config.reply_button_str: + notification.setResponsePlaceholder_(config.reply_button_str) + + return notification + + +# Hardcore way to dealloc an Objective-C class from https://github.com/albertz/chromehacking/blob/master/disposeClass.py +def dispose_of_objc_class(cls: Type): + """Deallocate an objective C class ('del cls' does not remove the class from memory).""" + address = int(re.search("0x[0-9a-f]+", repr(cls)).group(0), 16) + logger.info(f"Disposing of class '{cls.__name__}' at addr: {hex(address)}") + print(f"Disposing of class '{cls.__name__}' at addr: {hex(address)}") + ctypes.pythonapi.objc_disposeClassPair.restype = None + ctypes.pythonapi.objc_disposeClassPair.argtypes = (ctypes.c_void_p,) + ctypes.pythonapi.objc_disposeClassPair(address)