Skip to content

Commit

Permalink
Jorricks#14 Refactored to use a single side-car process
Browse files Browse the repository at this point in the history
  • Loading branch information
basilevs committed Oct 13, 2023
1 parent 6f0741d commit 417473b
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 79 deletions.
5 changes: 3 additions & 2 deletions examples/multiple_notifications_with_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@
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(
title="Message from Daniel",
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()
26 changes: 20 additions & 6 deletions src/mac_notifications/listener_process.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
38 changes: 21 additions & 17 deletions src/mac_notifications/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -38,21 +39,22 @@ 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
# 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
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()
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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.")
Expand Down
113 changes: 59 additions & 54 deletions src/mac_notifications/notification_sender.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import logging
import objc
from multiprocessing import SimpleQueue
from typing import Any

Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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

0 comments on commit 417473b

Please sign in to comment.