Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand All @@ -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 ;)!


Expand All @@ -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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down
2 changes: 2 additions & 0 deletions src/mac_notifications/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions src/mac_notifications/listener_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ 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.
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.
"""
Expand All @@ -25,5 +22,5 @@ def __init__(self, notification_config: JSONNotificationConfig, queue: SimpleQue
self.queue = queue

def run(self) -> None:
notification_sender.create_notification(self.notification_config, self.queue).send()
notification_sender.create_notification(self.notification_config, self.queue)
# on if any of the callbacks are provided, start the event loop (this will keep the program from stopping)
24 changes: 12 additions & 12 deletions src/mac_notifications/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from mac_notifications.listener_process import NotificationProcess
from mac_notifications.notification_config import NotificationConfig
from mac_notifications.singleton import Singleton
from mac_notifications.notification_sender import cancel_notification
from mac_notifications.notification_sender import cancel_notification, create_notification


"""
This is the module responsible for managing the notifications over time & enabling callbacks to be executed.
Expand Down Expand Up @@ -48,12 +49,13 @@ 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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jorricks why do you need both atexit() and custom SIGINT handling that do the exact same thing (call self.cleanup())? Seems like just atexit() should be sufficient to accomplish what you are going for?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I honestly do not recall.
I think we can indeed remove the catch_keyboard_interrupt but let's save that for another MR.


# Specify that when we get a keyboard interrupt, this function should handle it
signal.signal(signal.SIGINT, handler=self.catch_keyboard_interrupt)

Expand All @@ -73,20 +75,18 @@ 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:

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._callback_listener_process = NotificationProcess(json_config, self._callback_queue)
self._callback_listener_process.start()
self.create_callback_executor_thread()

if notification_config.contains_callback:
_FIFO_LIST.append(notification_config.uid)
_NOTIFICATION_MAP[notification_config.uid] = notification_config
else:
create_notification(json_config, None)

self.clear_old_notifications()
return Notification(notification_config.uid)

Expand All @@ -108,7 +108,7 @@ 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.raise_signal(signal.SIGINT)

def cleanup(self) -> None:
"""Stop all processes related to the Notification callback handling."""
Expand All @@ -125,7 +125,7 @@ def cleanup(self) -> None:

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):
Expand Down
3 changes: 3 additions & 0 deletions src/mac_notifications/notification_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
205 changes: 117 additions & 88 deletions src/mac_notifications/notification_sender.py
Original file line number Diff line number Diff line change
@@ -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)