diff --git a/docs/desktop-notifications.rst b/docs/desktop-notifications.rst index 5da9f77f6..46efb52e1 100644 --- a/docs/desktop-notifications.rst +++ b/docs/desktop-notifications.rst @@ -333,6 +333,25 @@ The terminal *must not* send a response unless report is requested with .. _notifications_query: +Playing a sound with notifications +----------------------------------------- + +.. versionadded:: 0.36.0 + The ability to control the sound played with notifications + +By default, notifications may or may not have a sound associated with them +depending on the policies of the OS notifications service. Sometimes it +might be useful to ensure a notification is not accompanied by a sound. +This can be done by using the ``s`` key which accepts :ref:`base64` encoded +UTF-8 text as its value. Using a value of ``silent`` means the notification +will not be accompanied with a sound. A value of ``system`` (the default) +means that the OS notifications default policies are followed. Any other name +is implementation dependent. For example, on Linux, one can use the `standard +sound names +`__. +Support for sounds can be queried as described below. + + Querying for support ------------------------- @@ -372,6 +391,12 @@ Key Value ``p`` key that the terminal implements). These must contain at least ``title``. +``s`` Comma separated list of keywords ``silent`` and ``xdg-names`` indicating + support for silent notifications and for passing of `Freedesktop + standard sound names + `__ to the + desktop notification service for custom sounds. + ``u`` Comma separated list of urgency values that the terminal implements. If urgency is not supported, the ``u`` key must be absent from the query response. @@ -441,6 +466,10 @@ Key Value Default Description ``?``, ``alive``, ``buttons`` +``s`` :ref:`base64` ``system`` The sound name to play with the notification. ``silent`` means no sound. + encoded sound ``system`` means to play the default sound, if any, of the platform notification service. + name Other names are implementation dependent. + ``t`` :ref:`base64` ``unset`` The type of the notification. Used to filter out notifications. Can be specified multiple times. encoded UTF-8 notification type diff --git a/docs/notifications.py b/docs/notifications.py index 60f0a002d..365dfae90 100644 --- a/docs/notifications.py +++ b/docs/notifications.py @@ -34,6 +34,9 @@ def main(nc: NotificationCommand) -> bool: if 'unwanted' in nc.title.lower(): return True + # force the notification to be silent + nc.sound_name = 'silent' + # filter out notifications from the application badapp if nc.application_name == 'badapp': return True diff --git a/glfw/glfw3.h b/glfw/glfw3.h index b4049b9d9..447080e56 100644 --- a/glfw/glfw3.h +++ b/glfw/glfw3.h @@ -1317,7 +1317,7 @@ typedef struct GLFWLayerShellConfig { typedef struct GLFWDBUSNotificationData { const char *app_name, *icon, *summary, *body, *category, **actions; size_t num_actions; - int32_t timeout; uint8_t urgency; uint32_t replaces; + int32_t timeout; uint8_t urgency; uint32_t replaces; int muted; } GLFWDBUSNotificationData; /*! @brief The function pointer type for error callbacks. diff --git a/glfw/linux_notify.c b/glfw/linux_notify.c index 98de7f086..617058473 100644 --- a/glfw/linux_notify.c +++ b/glfw/linux_notify.c @@ -178,6 +178,7 @@ glfw_dbus_send_user_notification(const GLFWDBUSNotificationData *n, GLFWDBusnoti } append_sv_dictionary_entry("urgency", DBUS_TYPE_BYTE, n->urgency); if (n->category && n->category[0]) append_sv_dictionary_entry("category", DBUS_TYPE_STRING, n->category); + if (n->muted) append_sv_dictionary_entry("suppress-sound", DBUS_TYPE_BOOLEAN, n->muted); check_call(dbus_message_iter_close_container, &args, &array); APPEND(args, DBUS_TYPE_INT32, n->timeout) diff --git a/kittens/notify/main.go b/kittens/notify/main.go index c1e26ba19..1d0ec8983 100644 --- a/kittens/notify/main.go +++ b/kittens/notify/main.go @@ -68,6 +68,9 @@ func (p *parsed_data) create_metadata() string { if p.opts.IconCacheId != "" { ans = append(ans, "g="+p.opts.IconCacheId) } + if p.opts.SoundName != "system" { + ans = append(ans, "s="+b64encode(p.opts.SoundName)) + } m := strings.Join(ans, ":") if m != "" { m = ":" + m diff --git a/kittens/notify/main.py b/kittens/notify/main.py index 04cff0955..0b5a32fed 100644 --- a/kittens/notify/main.py +++ b/kittens/notify/main.py @@ -50,6 +50,14 @@ def OPTIONS() -> str: action or OS policy. +--sound-name -s +default=system +The name of the sound to play with the notification. :code:`system` means let the +notification system use whatever sound it wants. :code:`silent` means prevent +any sound from being played. Any other value is passed to the desktop's notification system +which may or may not honor it. + + --type -t The notification type. Can be any string, it is used by users to create filter rules for notifications, so choose something descriptive of the notification's purpose. diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index f59c0c063..7d8d9ed9b 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -464,7 +464,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center } static void -schedule_notification(const char *appname, const char *identifier, const char *title, const char *body, const char *image_path, int urgency, const char *category_id) {@autoreleasepool { +schedule_notification(const char *appname, const char *identifier, const char *title, const char *body, const char *image_path, int urgency, const char *category_id, bool muted) {@autoreleasepool { UNUserNotificationCenter *center = get_notification_center_safely(); if (!center) return; // Configure the notification's payload. @@ -473,7 +473,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center if (body) content.body = @(body); if (appname) content.threadIdentifier = @(appname); if (category_id) content.categoryIdentifier = @(category_id); - content.sound = [UNNotificationSound defaultSound]; + if (!muted) content.sound = [UNNotificationSound defaultSound]; #if __MAC_OS_X_VERSION_MIN_REQUIRED >= 120000 switch (urgency) { case 0: @@ -521,7 +521,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center typedef struct { char *identifier, *title, *body, *appname, *image_path, *category_id; - int urgency; + int urgency; bool muted; } QueuedNotification; typedef struct { @@ -531,13 +531,13 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center static NotificationQueue notification_queue = {0}; static void -queue_notification(const char *appname, const char *identifier, const char *title, const char* body, const char *image_path, int urgency, const char *category_id) { +queue_notification(const char *appname, const char *identifier, const char *title, const char* body, const char *image_path, int urgency, const char *category_id, bool muted) { ensure_space_for((¬ification_queue), notifications, QueuedNotification, notification_queue.count + 16, capacity, 16, true); QueuedNotification *n = notification_queue.notifications + notification_queue.count++; #define d(x) n->x = (x && x[0]) ? strdup(x) : NULL; d(appname); d(identifier); d(title); d(body); d(image_path); d(category_id); #undef d - n->urgency = urgency; + n->urgency = urgency; n->muted = muted; } static void @@ -545,7 +545,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center if (granted) { for (size_t i = 0; i < notification_queue.count; i++) { QueuedNotification *n = notification_queue.notifications + i; - schedule_notification(n->appname, n->identifier, n->title, n->body, n->image_path, n->urgency, n->category_id); + schedule_notification(n->appname, n->identifier, n->title, n->body, n->image_path, n->urgency, n->category_id, n->muted); } } while(notification_queue.count) { @@ -598,17 +598,17 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center static PyObject* cocoa_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { const char *identifier = "", *title = "", *body = "", *appname = "", *image_path = ""; int urgency = 1; - PyObject *category, *categories; - static const char* kwlist[] = {"appname", "identifier", "title", "body", "category", "categories", "image_path", "urgency", NULL}; - if (!PyArg_ParseTupleAndKeywords(args, kw, "ssssOO!|si", (char**)kwlist, - &appname, &identifier, &title, &body, &category, &PyTuple_Type, &categories, &image_path, &urgency)) return NULL; + PyObject *category, *categories; int muted = 0; + static const char* kwlist[] = {"appname", "identifier", "title", "body", "category", "categories", "image_path", "urgency", "muted", NULL}; + if (!PyArg_ParseTupleAndKeywords(args, kw, "ssssOO!|sip", (char**)kwlist, + &appname, &identifier, &title, &body, &category, &PyTuple_Type, &categories, &image_path, &urgency, &muted)) return NULL; UNUserNotificationCenter *center = get_notification_center_safely(); if (!center) Py_RETURN_NONE; if (!center.delegate) center.delegate = [[NotificationDelegate alloc] init]; if (PyObject_IsTrue(categories)) if (!set_notification_categories(center, categories)) return NULL; RAII_PyObject(category_id, PyObject_GetAttrString(category, "id")); - queue_notification(appname, identifier, title, body, image_path, urgency, PyUnicode_AsUTF8(category_id)); + queue_notification(appname, identifier, title, body, image_path, urgency, PyUnicode_AsUTF8(category_id), muted); // The badge permission needs to be requested as well, even though it is not used, // otherwise macOS refuses to show the preference checkbox for enable/disable notification sound. diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index 30800fa2b..5cae77b64 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -1055,7 +1055,7 @@ typedef struct GLFWLayerShellConfig { typedef struct GLFWDBUSNotificationData { const char *app_name, *icon, *summary, *body, *category, **actions; size_t num_actions; - int32_t timeout; uint8_t urgency; uint32_t replaces; + int32_t timeout; uint8_t urgency; uint32_t replaces; int muted; } GLFWDBUSNotificationData; /*! @brief The function pointer type for error callbacks. diff --git a/kitty/glfw.c b/kitty/glfw.c index 9572aa7c2..ebd4684de 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -2086,10 +2086,10 @@ static PyObject* dbus_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { int timeout = -1, urgency = 1; unsigned int replaces = 0; GLFWDBUSNotificationData d = {0}; - static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "actions", "timeout", "urgency", "replaces", "category", NULL}; + static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "actions", "timeout", "urgency", "replaces", "category", "muted", NULL}; PyObject *actions = NULL; - if (!PyArg_ParseTupleAndKeywords(args, kw, "ssssO!|iiIs", (char**)kwlist, - &d.app_name, &d.icon, &d.summary, &d.body, &PyDict_Type, &actions, &timeout, &urgency, &replaces, &d.category)) return NULL; + if (!PyArg_ParseTupleAndKeywords(args, kw, "ssssO!|iiIsp", (char**)kwlist, + &d.app_name, &d.icon, &d.summary, &d.body, &PyDict_Type, &actions, &timeout, &urgency, &replaces, &d.category, &d.muted)) return NULL; if (!glfwDBusUserNotify) { PyErr_SetString(PyExc_RuntimeError, "Failed to load glfwDBusUserNotify, did you call glfw_init?"); return NULL; diff --git a/kitty/notifications.py b/kitty/notifications.py index 9db41861a..58882fc48 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -219,6 +219,7 @@ class NotificationCommand: notification_types: tuple[str, ...] = () timeout: int = -2 buttons: tuple[str, ...] = () + sound_name: str = '' # event callbacks on_activation: Optional[Callable[['NotificationCommand', int], None]] = None @@ -323,6 +324,11 @@ def parse_metadata(self, metadata: str, prev: 'NotificationCommand') -> tuple[Pa self.timeout = max(-1, int(v)) except Exception: self.log('Ignoring invalid timeout in notification: {v!r}') + elif k == 's': + try: + self.sound_name = base64_decode(v).decode('utf-8') + except Exception: + self.log('Ignoring invalid sound name in notification: {v!r}') if not prev.done and prev.identifier == self.identifier: self.merge_metadata(prev) return payload_type, payload_is_encoded @@ -347,6 +353,8 @@ def merge_metadata(self, prev: 'NotificationCommand') -> None: self.notification_types = prev.notification_types + self.notification_types if prev.buttons: self.buttons += prev.buttons + if not self.sound_name: + self.sound_name = prev.sound_name if self.timeout < -1: self.timeout = prev.timeout self.icon_path = prev.icon_path @@ -411,6 +419,7 @@ def finalise(self) -> None: self.urgency = Urgency.Normal if self.urgency is None else self.urgency self.close_response_requested = bool(self.close_response_requested) self.timeout = max(-1, self.timeout) + self.sound_name = self.sound_name or 'system' def matches_rule_item(self, location:str, query:str) -> bool: import re @@ -440,6 +449,8 @@ class DesktopIntegration: supports_close_events: bool = True supports_body: bool = True supports_buttons: bool = True + supports_sound: bool = True + supports_sound_names: str = 'xdg-names' def __init__(self, notification_manager: 'NotificationManager'): self.notification_manager = notification_manager @@ -475,7 +486,8 @@ def query_response(self, identifier: str) -> str: i = f'i={identifier or "0"}:' p = ','.join(x.value for x in PayloadType if x.value and self.payload_type_supported(x)) c = ':c=1' if self.supports_close_events else '' - return f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}{c}:w=1' + s = 'silent' + (f',{self.supports_sound_names}' if self.supports_sound_names else '') + return f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}{c}:w=1:s={s}' class MacOSNotificationCategory(NamedTuple): @@ -487,6 +499,7 @@ class MacOSNotificationCategory(NamedTuple): class MacOSIntegration(DesktopIntegration): supports_close_events: bool = False + supports_sound_names: str = '' def initialize(self) -> None: from .fast_data_types import cocoa_set_notification_activated_callback @@ -583,6 +596,7 @@ def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Opti cocoa_send_notification( nc.application_name or 'kitty', str(desktop_notification_id), nc.title, body, category=category, categories=categories, image_path=image_path, urgency=nc.urgency.value, + muted=nc.sound_name == 'silent', ) return desktop_notification_id @@ -671,7 +685,13 @@ def created(self, dbus_notification_id: int, desktop_notification_id: int) -> No if len(self.dbus_to_desktop) > 128: k, v = self.dbus_to_desktop.popitem(False) self.desktop_to_dbus.pop(v, None) - self.notification_manager.notification_created(dbus_notification_id) + if n := self.notification_manager.notification_created(dbus_notification_id): + # self.supports_sound does not tell us if the notification server + # supports named sounds or not so we play the named sound + # ourselves and tell the server to mute any sound it might play. + if n.sound_name not in ('system', 'silent'): + from .fast_data_types import play_desktop_sound_async + play_desktop_sound_async(n.sound_name, event_id='desktop notification') def dispatch_event_from_desktop(self, event_type: str, dbus_notification_id: int, extra: Union[int, str]) -> None: if event_type == 'capabilities': @@ -679,6 +699,7 @@ def dispatch_event_from_desktop(self, event_type: str, dbus_notification_id: int self.supports_body = 'body' in capabilities self.supports_buttons = 'actions' in capabilities self.supports_body_markup = 'body-markup' in capabilities + self.supports_sound = 'sound' in capabilities if debug_desktop_integration: log_error('Got notification server capabilities:', capabilities) return @@ -727,7 +748,9 @@ def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Opti actions[str(i+1)] = b desktop_notification_id = dbus_send_notification( app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, actions=actions, - timeout=nc.timeout, urgency=nc.urgency.value, replaces=replaces_dbus_id, category=(nc.notification_types or ('',))[0]) + timeout=nc.timeout, urgency=nc.urgency.value, replaces=replaces_dbus_id, + category=(nc.notification_types or ('',))[0], muted=nc.sound_name == 'silent' or nc.sound_name != 'system', + ) if debug_desktop_integration: log_error(f'Requested creation of notification with {desktop_notification_id=}') if existing_desktop_notification_id and replaces_dbus_id: @@ -830,11 +853,13 @@ def reset(self) -> None: self.in_progress_notification_commands_by_client_id: dict[str, NotificationCommand] = {} self.pending_commands: dict[int, NotificationCommand] = {} - def notification_created(self, desktop_notification_id: int) -> None: + def notification_created(self, desktop_notification_id: int) -> NotificationCommand | None: if n := self.in_progress_notification_commands.get(desktop_notification_id): n.created_by_desktop = True if n.timeout > 0: add_timer(partial(self.expire_notification, desktop_notification_id, id(n)), n.timeout / 1000, False) + return n + return None def notification_activation_token_received(self, desktop_notification_id: int, token: str) -> None: if n := self.in_progress_notification_commands.get(desktop_notification_id): diff --git a/kitty_tests/notifications.py b/kitty_tests/notifications.py index 9fbb0cdd2..4fd495ada 100644 --- a/kitty_tests/notifications.py +++ b/kitty_tests/notifications.py @@ -14,11 +14,11 @@ def n( title='title', body='', urgency=Urgency.Normal, desktop_notification_id=1, icon_names=(), icon_path='', - application_name='', notification_types=(), timeout=-1, + application_name='', notification_types=(), timeout=-1, sound='system', ): return { 'title': title, 'body': body, 'urgency': urgency, 'id': desktop_notification_id, 'icon_names': icon_names, 'icon_path': icon_path, - 'application_name': application_name, 'notification_types': notification_types, 'timeout': timeout + 'application_name': application_name, 'notification_types': notification_types, 'timeout': timeout, 'sound': sound, } @@ -53,7 +53,8 @@ def notify(self, cmd, existing_desktop_notification_id) -> int: self.counter += 1 did = self.counter title, body, urgency = cmd.title, cmd.body, (Urgency.Normal if cmd.urgency is None else cmd.urgency) - ans = n(title, body, urgency, did, cmd.icon_names, os.path.basename(cmd.icon_path), cmd.application_name, cmd.notification_types, timeout=cmd.timeout) + ans = n(title, body, urgency, did, cmd.icon_names, os.path.basename(cmd.icon_path), cmd.application_name, + cmd.notification_types, timeout=cmd.timeout, sound=cmd.sound_name) self.notifications.append(ans) return self.counter @@ -235,10 +236,20 @@ def assert_events(focus=True, close=0, report='', close_response='', live=''): assert_events() reset() + # test sounds + def enc(x): + return standard_b64encode(x.encode()).decode() + + h(f's={enc("silent")};title') + self.ae(di.notifications, [n(sound='silent')]) + h(f's={enc("custom")};title') + self.ae(di.notifications[-1], n(desktop_notification_id=2, sound='custom')) + reset() + # Test querying h('i=xyz:p=?') self.assertFalse(di.notifications) - qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close,icon,alive,buttons:c=1:w=1' + qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close,icon,alive,buttons:c=1:w=1:s=silent,xdg-names' self.ae(ch.responses, [f'99;i=xyz:p=?;{qr}']) reset() h('p=?')