From 417473b951ce240163121a47a2c7c3ce95c861ce Mon Sep 17 00:00:00 2001 From: Vasili Gulevich Date: Sat, 14 Oct 2023 00:43:58 +0400 Subject: [PATCH] #14 Refactored to use a single side-car process --- .../multiple_notifications_with_callbacks.py | 5 +- src/mac_notifications/listener_process.py | 26 +++- src/mac_notifications/manager.py | 38 +++--- src/mac_notifications/notification_sender.py | 113 +++++++++--------- 4 files changed, 103 insertions(+), 79 deletions(-) diff --git a/examples/multiple_notifications_with_callbacks.py b/examples/multiple_notifications_with_callbacks.py index 55f77f8..9632ab7 100644 --- a/examples/multiple_notifications_with_callbacks.py +++ b/examples/multiple_notifications_with_callbacks.py @@ -21,7 +21,7 @@ subtitle="Hey Dude, are we still meeting?", icon=Path(__file__).parent / "img" / "chat.png", reply_button_str="Reply to this notification", - reply_callback=lambda reply: print(f"You replied: {reply}"), + reply_callback=lambda reply: print(f"You replied to Henk: {reply}"), ) print("Yet another message.") client.create_notification( @@ -29,10 +29,11 @@ subtitle="How you doing?", icon=Path(__file__).parent / "img" / "chat.png", reply_button_str="Reply to this notification", - reply_callback=lambda reply: print(f"You replied: {reply}"), + reply_callback=lambda reply: print(f"You replied to Daniel: {reply}"), ) print("Application will remain active until both notifications have been answered.") while client.get_notification_manager().get_active_running_notifications() > 0: time.sleep(1) + print("all notifications are handled") client.stop_listening_for_callbacks() diff --git a/src/mac_notifications/listener_process.py b/src/mac_notifications/listener_process.py index 2159737..75ac497 100644 --- a/src/mac_notifications/listener_process.py +++ b/src/mac_notifications/listener_process.py @@ -1,6 +1,8 @@ from __future__ import annotations from multiprocessing import Process, SimpleQueue +from multiprocessing.connection import Connection +from threading import Thread from mac_notifications import notification_sender from mac_notifications.notification_config import JSONNotificationConfig @@ -19,11 +21,23 @@ class NotificationProcess(Process): 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 __init__(self, connection: Connection): + super().__init__(daemon=True) + self.connection = connection + + + def poll(self): + try: + while True: + notification_config: JSONNotificationConfig = self.connection.recv() + notification_sender.send_notification(notification_config) + except EOFError: + pass + + def handle_activation(self, activation): + self.connection.send(activation) 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) + poll_thread = Thread(None, self.poll, daemon=True) + poll_thread.start() + notification_sender.wait_activations(self.handle_activation) diff --git a/src/mac_notifications/manager.py b/src/mac_notifications/manager.py index fc229db..8749022 100644 --- a/src/mac_notifications/manager.py +++ b/src/mac_notifications/manager.py @@ -2,10 +2,11 @@ import atexit import logging +from multiprocessing.connection import Connection import signal import sys import time -from multiprocessing import SimpleQueue +from multiprocessing import Pipe, SimpleQueue from threading import Event, Thread from typing import Dict, List @@ -38,7 +39,6 @@ class NotificationManager(metaclass=Singleton): """ def __init__(self): - self._callback_queue: SimpleQueue = SimpleQueue() self._callback_executor_event: Event = Event() self._callback_executor_thread: CallbackExecutorThread | None = None self._callback_listener_process: NotificationProcess | None = None @@ -46,13 +46,15 @@ def __init__(self): atexit.register(self.cleanup) # Specify that when we get a keyboard interrupt, this function should handle it signal.signal(signal.SIGINT, handler=self.catch_keyboard_interrupt) + self.parent_pipe_end: Connection = None + self.child_pipe_end: Connection = None def create_callback_executor_thread(self) -> None: """Creates the callback executor thread and sets the _callback_executor_event.""" if not (self._callback_executor_thread and self._callback_executor_thread.is_alive()): self._callback_executor_thread = CallbackExecutorThread( keep_running=self._callback_executor_event, - callback_queue=self._callback_queue, + callback_queue=self.parent_pipe_end, ) self._callback_executor_event.set() self._callback_executor_thread.start() @@ -63,20 +65,17 @@ def create_notification(self, notification_config: NotificationConfig) -> None: :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: + if not self._callback_listener_process: # 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.parent_pipe_end, self.child_pipe_end = Pipe() + self._callback_listener_process = NotificationProcess(self.child_pipe_end) self._callback_listener_process.start() self.create_callback_executor_thread() + self.parent_pipe_end.send(json_config) - if notification_config.contains_callback: - _FIFO_LIST.append(notification_config.uid) - _NOTIFICATION_MAP[notification_config.uid] = notification_config + _FIFO_LIST.append(notification_config.uid) + _NOTIFICATION_MAP[notification_config.uid] = notification_config self.clear_old_notifications() @staticmethod @@ -117,10 +116,10 @@ class CallbackExecutorThread(Thread): Background threat that checks each 0.1 second whether there are any callbacks that it should execute. """ - def __init__(self, keep_running: Event, callback_queue: SimpleQueue): + def __init__(self, keep_running: Event, callback_queue: Connection): super().__init__() self.event_indicating_to_continue = keep_running - self.callback_queue = callback_queue + self.callback_queue: Connection = callback_queue def run(self) -> None: while self.event_indicating_to_continue.is_set(): @@ -133,8 +132,13 @@ def drain_queue(self) -> None: added to the `callback_queue`. This background Threat is then responsible for listening in on the callback_queue and when there is a callback it should execute, it executes it. """ - while not self.callback_queue.empty(): - msg = self.callback_queue.get() + while True: + try : + if not self.callback_queue.poll(1): + break + msg = self.callback_queue.recv() + except EOFError: + break notification_uid, event_id, reply_text = msg if notification_uid not in _NOTIFICATION_MAP: logger.debug(f"Received a notification interaction for {notification_uid} which we don't know.") diff --git a/src/mac_notifications/notification_sender.py b/src/mac_notifications/notification_sender.py index 7081991..57bd03c 100644 --- a/src/mac_notifications/notification_sender.py +++ b/src/mac_notifications/notification_sender.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import objc from multiprocessing import SimpleQueue from typing import Any @@ -11,13 +12,16 @@ from mac_notifications.notification_config import JSONNotificationConfig logger = logging.getLogger() +logging.basicConfig(level = logging.DEBUG) """ This module is responsible for creating the notifications in the C-layer and listening/reporting about user activity. """ +class NativeNotification(object): + def cancel(self) -> None: + pass - -def create_notification(config: JSONNotificationConfig, queue_to_submit_events_to: SimpleQueue | None) -> Any: +def send_notification(config: JSONNotificationConfig) -> NativeNotification: """ Create a notification and possibly listed & report about notification activity. :param config: The configuration of the notification to send. @@ -26,50 +30,54 @@ def create_notification(config: JSONNotificationConfig, queue_to_submit_events_t 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) + 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) + + # 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) + + class Wrapper(NativeNotification): + def cancel(self): + return NSUserNotificationCenter.defaultUserNotificationCenter().removeDeliveredNotification_(notification) - # 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) + # return notification + return Wrapper() - # 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 wait_activations(handle_activations): + class NSUserNotificationCenterDelegate(NSObject): + def init(self): + self = objc.super(NSUserNotificationCenterDelegate, self).init() + NSUserNotificationCenter.defaultUserNotificationCenter().setDelegate_(self) + return self def userNotificationCenter_didDeliverNotification_( self, center: "_NSConcreteUserNotificationCenter", notif: "_NSConcreteUserNotification" # type: ignore # noqa ) -> None: @@ -86,24 +94,21 @@ def userNotificationCenter_didActivateNotification_( 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", "")) + handle_activations((identifier, "action_button_clicked", "")) elif activation_type == 3: # User clicked on the reply button - queue.put((identifier, "reply_button_clicked", response.string())) + handle_activations((identifier, "reply_button_clicked", response.string())) - # create the new notification - new_notif = MacOSNotification.alloc().init() + do_not_garbage_collect_me = NSUserNotificationCenterDelegate.alloc().init() + + # Wait for the notification CallBack to happen. + logger.debug("Started listening for user interactions with notifications.") + AppHelper.runConsoleEventLoop() + logger.debug("Stopped listening for user interactions") - # return notification - return new_notif