Add support for controlling the sound played with notifications

This commit is contained in:
Kovid Goyal 2024-08-03 13:46:38 +05:30
parent 5e809d2767
commit bd8162fa42
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
11 changed files with 104 additions and 24 deletions

View file

@ -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
<https://specifications.freedesktop.org/sound-naming-spec/latest/#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
<https://specifications.freedesktop.org/sound-naming-spec/latest/#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

View file

@ -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

2
glfw/glfw3.h vendored
View file

@ -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.

1
glfw/linux_notify.c vendored
View file

@ -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)

View file

@ -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

View file

@ -50,6 +50,14 @@ after the specified time has elapsed. The notification could be closed before by
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.

View file

@ -464,7 +464,7 @@ live_delivered_notifications(void) {
}
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 @@ schedule_notification(const char *appname, const char *identifier, const char *t
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 @@ schedule_notification(const char *appname, const char *identifier, const char *t
typedef struct {
char *identifier, *title, *body, *appname, *image_path, *category_id;
int urgency;
int urgency; bool muted;
} QueuedNotification;
typedef struct {
@ -531,13 +531,13 @@ typedef struct {
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((&notification_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 @@ drain_pending_notifications(BOOL granted) {
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 @@ set_notification_categories(UNUserNotificationCenter *center, PyObject *categori
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.

2
kitty/glfw-wrapper.h generated
View file

@ -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.

View file

@ -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;

View file

@ -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 @@ class NotificationCommand:
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 @@ class NotificationCommand:
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 @@ class NotificationCommand:
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 @@ class DesktopIntegration:
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 @@ class MacOSIntegration(DesktopIntegration):
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 @@ class FreeDesktopIntegration(DesktopIntegration):
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 @@ class FreeDesktopIntegration(DesktopIntegration):
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 @@ class FreeDesktopIntegration(DesktopIntegration):
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 @@ class NotificationManager:
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):

View file

@ -14,11 +14,11 @@ from . import BaseTest
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 @@ class DesktopIntegration(DesktopIntegration):
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 do_test(self: 'TestNotifications', tdir: str) -> None:
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=?')