Implement updating of notifications

This commit is contained in:
Kovid Goyal 2024-07-28 12:25:35 +05:30
parent 59c175f312
commit 2bffea2bdc
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
9 changed files with 96 additions and 54 deletions

View file

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

2
glfw/glfw3.h vendored
View file

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

3
glfw/linux_notify.c vendored
View file

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

View file

@ -549,14 +549,13 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
}
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 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
typedef struct {
char *identifier, *title, *body, *subtitle;
char *identifier, *title, *body;
int urgency;
} QueuedNotification;
@ -605,13 +604,12 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
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((&notification_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 @@ - (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->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 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
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.

View file

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

3
kitty/glfw-wrapper.h generated
View file

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

View file

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

View file

@ -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 @@ def focus_requested(self) -> bool:
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 @@ def initialize(self) -> None:
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 @@ def close_notification(self, desktop_notification_id: int) -> bool:
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 @@ def initialize(self) -> None:
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 @@ def close_notification(self, desktop_notification_id: int) -> bool:
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 @@ def dispatch_event_from_desktop(self, event_type: str, dbus_notification_id: int
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 @@ def notification_activated(self, desktop_notification_id: int) -> None:
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 @@ def notify_with_command(self, cmd: NotificationCommand, channel_id: int) -> Opti
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:

View file

@ -39,10 +39,14 @@ def close_notification(self, desktop_notification_id: int) -> bool:
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 send_with_icon(data='', n='', g=''):
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()