diff --git a/docs/desktop-notifications.rst b/docs/desktop-notifications.rst index 93d2db967..eacdb0a32 100644 --- a/docs/desktop-notifications.rst +++ b/docs/desktop-notifications.rst @@ -127,7 +127,9 @@ escape code to inform when the notification is closed:: If no notification id was specified ``i=0`` will be used. If ``a=report`` is specified and the notification is activated/clicked on -then both the activation report and close notification are sent. +then both the activation report and close notification are sent. If the notification +is updated then the close event is not sent unless the updated notification +also requests a close notification. .. note:: On macOS the OS does not supply notification closed events to applications. As such close events must be implemented @@ -137,11 +139,19 @@ then both the activation report and close notification are sent. being authoritative. -Closing an existing notification ----------------------------------- +Updating or closing an existing notification +---------------------------------------------- .. versionadded:: 0.36.0 - The ability to close a previous notification was added in kitty 0.36.0 + The ability to update and close a previous notification was added in kitty 0.36.0 + +To update a previous notification simply send a new notification with the same +*notification id* (``i`` key) as the one you want to update. If the original +notification is still displayed it will be replaced, otherwise a new +notification is displayed. This can be used, for example, to show progress of +an operation. Note that how smoothly the existing notification is replaced +depends on the underlying OS, for example, on Linux the replacement is usually flicker +free, on macOS it isn't, because of Apple's design choices. To close a previous notification, send:: diff --git a/glfw/glfw3.h b/glfw/glfw3.h index bea6f9095..e8d7a33b6 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, *action_name; - int32_t timeout; uint8_t urgency; + int32_t timeout; uint8_t urgency; uint32_t replaces; } GLFWDBUSNotificationData; /*! @brief The function pointer type for error callbacks. diff --git a/glfw/linux_notify.c b/glfw/linux_notify.c index 1d6d258cb..87a2eb726 100644 --- a/glfw/linux_notify.c +++ b/glfw/linux_notify.c @@ -111,7 +111,6 @@ glfw_dbus_send_user_notification(const GLFWDBUSNotificationData *n, GLFWDBusnoti data->next_id = ++notification_id; data->callback = callback; data->data = user_data; if (!data->next_id) data->next_id = ++notification_id; - uint32_t replaces_id = 0; RAII_MSG(msg, dbus_message_new_method_call(NOTIFICATIONS_SERVICE, NOTIFICATIONS_PATH, NOTIFICATIONS_IFACE, "Notify")); if (!msg) { return 0; } @@ -120,7 +119,7 @@ glfw_dbus_send_user_notification(const GLFWDBUSNotificationData *n, GLFWDBusnoti #define check_call(func, ...) if (!func(__VA_ARGS__)) { _glfwInputError(GLFW_PLATFORM_ERROR, "%s", "Out of memory allocating DBUS message for notification\n"); return 0; } #define APPEND(to, type, val) check_call(dbus_message_iter_append_basic, &to, type, &val); APPEND(args, DBUS_TYPE_STRING, n->app_name) - APPEND(args, DBUS_TYPE_UINT32, replaces_id) + APPEND(args, DBUS_TYPE_UINT32, n->replaces) APPEND(args, DBUS_TYPE_STRING, n->icon) APPEND(args, DBUS_TYPE_STRING, n->summary) APPEND(args, DBUS_TYPE_STRING, n->body) diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 4041729d8..e5648e83b 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -549,14 +549,13 @@ track_notification(char *ident) { } static void -schedule_notification(const char *identifier, const char *title, const char *body, const char *subtitle, int urgency) { +schedule_notification(const char *identifier, const char *title, const char *body, int urgency) { UNUserNotificationCenter *center = get_notification_center_safely(); if (!center) return; // Configure the notification's payload. UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; if (title) content.title = @(title); if (body) content.body = @(body); - if (subtitle) content.subtitle = @(subtitle); content.sound = [UNNotificationSound defaultSound]; #if __MAC_OS_X_VERSION_MIN_REQUIRED >= 120000 switch (urgency) { @@ -594,7 +593,7 @@ schedule_notification(const char *identifier, const char *title, const char *bod typedef struct { - char *identifier, *title, *body, *subtitle; + char *identifier, *title, *body; int urgency; } QueuedNotification; @@ -605,13 +604,12 @@ typedef struct { static NotificationQueue notification_queue = {0}; static void -queue_notification(const char *identifier, const char *title, const char* body, const char* subtitle, int urgency) { +queue_notification(const char *identifier, const char *title, const char* body, int urgency) { ensure_space_for((¬ification_queue), notifications, QueuedNotification, notification_queue.count + 16, capacity, 16, true); QueuedNotification *n = notification_queue.notifications + notification_queue.count++; n->identifier = identifier ? strdup(identifier) : NULL; n->title = title ? strdup(title) : NULL; n->body = body ? strdup(body) : NULL; - n->subtitle = subtitle ? strdup(subtitle) : NULL; n->urgency = urgency; } @@ -620,13 +618,13 @@ 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->identifier, n->title, n->body, n->subtitle, n->urgency); + schedule_notification(n->identifier, n->title, n->body, n->urgency); } } while(notification_queue.count) { QueuedNotification *n = notification_queue.notifications + --notification_queue.count; if (!granted) do_notification_callback(@(n->identifier), "closed"); - free(n->identifier); free(n->title); free(n->body); free(n->subtitle); + free(n->identifier); free(n->title); free(n->body); memset(n, 0, sizeof(QueuedNotification)); } } @@ -640,13 +638,13 @@ cocoa_remove_delivered_notification(PyObject *self UNUSED, PyObject *x) { static PyObject* cocoa_send_notification(PyObject *self UNUSED, PyObject *args) { - char *identifier = NULL, *title = NULL, *body = NULL, *subtitle = NULL; int urgency = 1; - if (!PyArg_ParseTuple(args, "ssz|zi", &identifier, &title, &body, &subtitle, &urgency)) return NULL; + char *identifier = NULL, *title = NULL, *body = NULL; int urgency = 1; + if (!PyArg_ParseTuple(args, "sss|i", &identifier, &title, &body, &urgency)) return NULL; UNUserNotificationCenter *center = get_notification_center_safely(); if (!center) Py_RETURN_NONE; if (!center.delegate) center.delegate = [[NotificationDelegate alloc] init]; - queue_notification(identifier, title, body, subtitle, urgency); + queue_notification(identifier, title, body, urgency); // 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/fast_data_types.pyi b/kitty/fast_data_types.pyi index 0580f5f92..0090358f3 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -556,6 +556,7 @@ def dbus_send_notification( action_text: str = '', timeout: int = -1, urgency: int = 1, + replaces: int = 0, ) -> int: pass @@ -566,8 +567,7 @@ def dbus_close_notification(dbus_notification_id: int) -> bool: ... def cocoa_send_notification( identifier: str, title: str, - body: Optional[str], - subtitle: Optional[str], + body: str, urgency: int = 1, ) -> None: pass diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index 4336f2c49..916ea3816 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, *action_name; - int32_t timeout; uint8_t urgency; + int32_t timeout; uint8_t urgency; uint32_t replaces; } GLFWDBUSNotificationData; /*! @brief The function pointer type for error callbacks. @@ -1705,7 +1705,6 @@ typedef void (* GLFWcocoarenderframefun)(GLFWwindow*); typedef void (*GLFWwaylandframecallbackfunc)(unsigned long long id); typedef void (*GLFWDBusnotificationcreatedfun)(unsigned long long, uint32_t, void*); typedef void (*GLFWDBusnotificationactivatedfun)(uint32_t, int, const char*); - typedef int (*glfwInit_func)(monotonic_t); GFW_EXTERN glfwInit_func glfwInit_impl; #define glfwInit glfwInit_impl diff --git a/kitty/glfw.c b/kitty/glfw.c index 198b1391d..5e1a71301 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -2076,17 +2076,18 @@ dbus_notification_created_callback(unsigned long long notification_id, uint32_t static PyObject* dbus_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { - int timeout = -1, urgency = 1; + int timeout = -1, urgency = 1; unsigned int replaces = 0; GLFWDBUSNotificationData d = {.action_name=""}; - static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "action_text", "timeout", "urgency", NULL}; - if (!PyArg_ParseTupleAndKeywords(args, kw, "ssss|sii", (char**)kwlist, - &d.app_name, &d.icon, &d.summary, &d.body, &d.action_name, &timeout, &urgency)) return NULL; + static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "action_text", "timeout", "urgency", "replaces", NULL}; + if (!PyArg_ParseTupleAndKeywords(args, kw, "ssss|siiI", (char**)kwlist, + &d.app_name, &d.icon, &d.summary, &d.body, &d.action_name, &timeout, &urgency, &replaces)) return NULL; if (!glfwDBusUserNotify) { PyErr_SetString(PyExc_RuntimeError, "Failed to load glfwDBusUserNotify, did you call glfw_init?"); return NULL; } d.timeout = timeout; d.urgency = urgency & 3; + d.replaces = replaces; unsigned long long notification_id = glfwDBusUserNotify(&d, dbus_notification_created_callback, NULL); return PyLong_FromUnsignedLongLong(notification_id); } diff --git a/kitty/notifications.py b/kitty/notifications.py index f409311fa..b9b01d930 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -204,6 +204,7 @@ class NotificationCommand: # event callbacks on_activation: Optional[Callable[['NotificationCommand'], None]] = None on_close: Optional[Callable[['NotificationCommand'], None]] = None + on_update: Optional[Callable[['NotificationCommand', 'NotificationCommand'], None]] = None # metadata identifier: str = '' @@ -234,9 +235,12 @@ class NotificationCommand: return Action.focus in self.actions def __repr__(self) -> str: - return ( - f'NotificationCommand(identifier={self.identifier!r}, title={self.title!r}, body={self.body!r},' - f'actions={self.actions}, done={self.done!r}, urgency={self.urgency})') + fields = {} + for x in ('title', 'body', 'identifier', 'actions', 'urgency', 'done'): + val = getattr(self, x) + if val: + fields[x] = val + return f'NotificationCommand{fields}' def parse_metadata(self, metadata: str, prev: 'NotificationCommand') -> Tuple[PayloadType, bool]: payload_type = PayloadType.title @@ -408,7 +412,7 @@ class DesktopIntegration: def close_notification(self, desktop_notification_id: int) -> bool: raise NotImplementedError('Implement me in subclass') - def notify(self, nc: NotificationCommand) -> int: + def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Optional[int]) -> int: raise NotImplementedError('Implement me in subclass') def on_new_version_notification_activation(self, cmd: NotificationCommand) -> None: @@ -439,18 +443,18 @@ class MacOSIntegration(DesktopIntegration): log_error(f'Close request for {desktop_notification_id=} {"succeeded" if close_succeeded else "failed"}') return close_succeeded - def notify(self, nc: NotificationCommand) -> int: - desktop_notification_id = next(self.id_counter) + def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Optional[int]) -> int: + desktop_notification_id = existing_desktop_notification_id or next(self.id_counter) from .fast_data_types import cocoa_send_notification # If the body is not set macos makes the title the body and uses # "kitty" as the title. So use a single space for the body in this # case. Although https://developer.apple.com/documentation/usernotifications/unnotificationcontent/body?language=objc - # says printf style strings are stripped this doesnt actually happen, + # says printf style strings are stripped this does not actually happen, # so dont double % # for %% escaping. body = (nc.body or ' ') assert nc.urgency is not None - cocoa_send_notification(str(desktop_notification_id), nc.title, body, '', nc.urgency.value) + cocoa_send_notification(str(desktop_notification_id), nc.title, body, nc.urgency.value) return desktop_notification_id def notification_activated(self, event: str, ident: str) -> None: @@ -476,7 +480,8 @@ class FreeDesktopIntegration(DesktopIntegration): dbus_set_notification_callback(self.dispatch_event_from_desktop) # map the id returned by the notification daemon to the # desktop_notification_id we use for the notification - self.creation_id_map: 'OrderedDict[int, int]' = OrderedDict() + self.dbus_to_desktop: 'OrderedDict[int, int]' = OrderedDict() + self.desktop_to_dbus: Dict[int, int] = {} def close_notification(self, desktop_notification_id: int) -> bool: from .fast_data_types import dbus_close_notification @@ -488,28 +493,32 @@ class FreeDesktopIntegration(DesktopIntegration): return close_succeeded def get_desktop_notification_id(self, dbus_notification_id: int, event: str) -> Optional[int]: - q = self.creation_id_map.get(dbus_notification_id) + q = self.dbus_to_desktop.get(dbus_notification_id) if q is None: if debug_desktop_integration: log_error(f'Could not find desktop_notification_id for {dbus_notification_id=} for event {event}') return q def get_dbus_notification_id(self, desktop_notification_id: int, event: str) ->Optional[int]: - for dbus_id, q in self.creation_id_map.items(): - if q == desktop_notification_id: - return dbus_id - if debug_desktop_integration: - log_error(f'Could not find dbus_notification_id for {desktop_notification_id=} for event {event}') - return None + q = self.desktop_to_dbus.get(desktop_notification_id) + if q is None: + if debug_desktop_integration: + log_error(f'Could not find dbus_notification_id for {desktop_notification_id=} for event {event}') + return q + + def created(self, dbus_notification_id: int, desktop_notification_id: int) -> None: + self.dbus_to_desktop[desktop_notification_id] = dbus_notification_id + self.desktop_to_dbus[dbus_notification_id] = desktop_notification_id + 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) def dispatch_event_from_desktop(self, event_type: str, dbus_notification_id: int, extra: Union[int, str]) -> None: if debug_desktop_integration: log_error(f'Got notification event from desktop: {event_type=} {dbus_notification_id=} {extra=}') if event_type == 'created': - self.creation_id_map[int(extra)] = dbus_notification_id - if len(self.creation_id_map) > 128: - self.creation_id_map.popitem(False) - self.notification_manager.notification_created(dbus_notification_id) + self.created(dbus_notification_id, int(extra)) return if desktop_notification_id := self.get_desktop_notification_id(dbus_notification_id, event_type): if event_type == 'activation_token': @@ -519,15 +528,22 @@ class FreeDesktopIntegration(DesktopIntegration): elif event_type == 'closed': self.notification_manager.notification_closed(desktop_notification_id) - def notify(self, nc: NotificationCommand) -> int: + def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Optional[int]) -> int: from .fast_data_types import dbus_send_notification app_icon = nc.icon_name or nc.icon_path or get_custom_window_icon()[1] or logo_png_file body = nc.body.replace('<', '<\u200c').replace('&', '&\u200c') # prevent HTML markup from being recognized assert nc.urgency is not None + replaces_dbus_id = 0 + if existing_desktop_notification_id: + replaces_dbus_id = self.get_dbus_notification_id(existing_desktop_notification_id, 'notify') or 0 desktop_notification_id = dbus_send_notification( - app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, timeout=-1, urgency=nc.urgency.value) + app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, timeout=-1, urgency=nc.urgency.value, + replaces=replaces_dbus_id) if debug_desktop_integration: - log_error(f'Created notification with {desktop_notification_id=}') + log_error(f'Requested creation of notification with {desktop_notification_id=}') + if existing_desktop_notification_id and replaces_dbus_id: + self.dbus_to_desktop.pop(replaces_dbus_id, None) + self.desktop_to_dbus.pop(existing_desktop_notification_id, None) return desktop_notification_id @@ -644,6 +660,15 @@ class NotificationManager: except Exception as e: self.log('Notification on_activation handler failed with error:', e) + def notification_replaced(self, old_cmd: NotificationCommand, new_cmd: NotificationCommand) -> None: + if old_cmd.desktop_notification_id != new_cmd.desktop_notification_id: + self.in_progress_notification_commands.pop(old_cmd.desktop_notification_id, None) + if old_cmd.on_update is not None: + try: + old_cmd.on_update(old_cmd, new_cmd) + except Exception as e: + self.log('Notification on_update handler failed with error:', e) + def notification_closed(self, desktop_notification_id: int) -> None: if n := self.in_progress_notification_commands.get(desktop_notification_id): self.purge_notification(n) @@ -704,8 +729,14 @@ class NotificationManager: cmd.finalise() if not cmd.title or not self.is_notification_allowed(cmd, channel_id) or self.is_notification_filtered(cmd): return None - desktop_notification_id = self.desktop_integration.notify(cmd) + existing_desktop_notification_id: Optional[int] = None + existing_cmd = self.in_progress_notification_commands_by_client_id.get(cmd.identifier) if cmd.identifier else None + if existing_cmd: + existing_desktop_notification_id = existing_cmd.desktop_notification_id + desktop_notification_id = self.desktop_integration.notify(cmd, existing_desktop_notification_id) self.register_in_progress_notification(cmd, desktop_notification_id) + if existing_cmd: + self.notification_replaced(existing_cmd, cmd) return desktop_notification_id def register_in_progress_notification(self, cmd: NotificationCommand, desktop_notification_id: int) -> None: diff --git a/kitty_tests/notifications.py b/kitty_tests/notifications.py index 57755965a..8a4edecd0 100644 --- a/kitty_tests/notifications.py +++ b/kitty_tests/notifications.py @@ -39,10 +39,14 @@ class DesktopIntegration(DesktopIntegration): self.notification_manager.notification_closed(desktop_notification_id) return self.close_succeeds - def notify(self, cmd) -> int: - self.counter += 1 + def notify(self, cmd, existing_desktop_notification_id) -> int: + if existing_desktop_notification_id: + did = existing_desktop_notification_id + else: + 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, self.counter, cmd.icon_name, os.path.basename(cmd.icon_path), cmd.application_name, cmd.notification_type) + ans = n(title, body, urgency, did, cmd.icon_name, os.path.basename(cmd.icon_path), cmd.application_name, cmd.notification_type) self.notifications.append(ans) return self.counter @@ -275,9 +279,9 @@ def do_test(self: 'TestNotifications', tdir: str) -> None: send_with_icon(g='gid', data='1') self.ae(di.notifications, [n(icon_path=dc.hash(b'1'))]) send_with_icon(g='gid', n='moose') - self.ae(di.notifications[-1], n(icon_name='moose', icon_path=dc.hash(b'1'), desktop_notification_id=len(di.notifications))) + self.ae(di.notifications[-1], n(icon_name='moose', icon_path=dc.hash(b'1'))) send_with_icon(g='gid2', data='2') - self.ae(di.notifications[-1], n(icon_path=dc.hash(b'2'), desktop_notification_id=len(di.notifications))) + self.ae(di.notifications[-1], n(icon_path=dc.hash(b'2'))) reset()