diff --git a/docs/desktop-notifications.rst b/docs/desktop-notifications.rst index a6ba597d6..f8d3deb8d 100644 --- a/docs/desktop-notifications.rst +++ b/docs/desktop-notifications.rst @@ -132,14 +132,22 @@ is updated then the close event is not sent unless the updated notification also requests a close notification. Note that on some platforms, such as macOS, the OS does not inform applications -when notifications are closed, on such platforms, terminals may reply with:: +when notifications are closed, on such platforms, terminals reply with:: 99 ; i=mynotification : p=close ; untracked This means that the terminal has no way of knowing when the notification is -closed. |kitty|, on macOS, manually tracks notifications by polling the OS -for a short period to see if they are closed, after which it gives up -and replies with an ``untracked`` response. +closed. Instead, applications can poll the terminal to determine which +notifications are still alive (not closed), with:: + + 99 ; i=myid : p=alive ; + +The terminal will reply with:: + 99 ; i=myid : p=alive ; id1,id2,id3 + +Here, ``myid`` is present for multiplxer support. The reponse from the terminal +contains a comma separated list of ids that are still alive. + Updating or closing an existing notification ---------------------------------------------- @@ -244,7 +252,7 @@ Key Value Default Description ``p`` One of ``title``, ``title`` Whether the payload is the notification title or body or query. If a ``body``, notification has no title, the body will be used as title. Terminal ``close``, emulators should ignore payloads of unknown type to allow for future - ``?`` expansion of this protocol. + ``?``, ``alive`` expansion of this protocol. ``o`` One of ``always``, ``always`` When to honor the notification request. ``unfocused`` means when the window diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index 6d67d0ac1..dbfaed2c4 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -1145,7 +1145,7 @@ static bool has_cocoa_pending_actions = false; typedef struct cocoa_list { char **items; size_t count, capacity; } cocoa_list; typedef struct { char* wd; - cocoa_list open_urls, closed_notifications, untracked_notifications; + cocoa_list open_urls, untracked_notifications; } CocoaPendingActionsData; static CocoaPendingActionsData cocoa_pending_actions_data = {0}; @@ -1165,7 +1165,6 @@ static void cocoa_free_actions_data(void) { if (cocoa_pending_actions_data.wd) { free(cocoa_pending_actions_data.wd); cocoa_pending_actions_data.wd = NULL; } cocoa_free_pending_list(&cocoa_pending_actions_data.open_urls); - cocoa_free_pending_list(&cocoa_pending_actions_data.closed_notifications); cocoa_free_pending_list(&cocoa_pending_actions_data.untracked_notifications); } @@ -1175,8 +1174,6 @@ set_cocoa_pending_action(CocoaPendingAction action, const char *data) { switch(action) { case LAUNCH_URLS: cocoa_append_to_pending_list(&cocoa_pending_actions_data.open_urls, data); break; - case COCOA_NOTIFICATION_CLOSED: - cocoa_append_to_pending_list(&cocoa_pending_actions_data.closed_notifications, data); break; case COCOA_NOTIFICATION_UNTRACKED: cocoa_append_to_pending_list(&cocoa_pending_actions_data.untracked_notifications, data); break; default: @@ -1230,18 +1227,9 @@ process_cocoa_pending_actions(void) { } cocoa_pending_actions_data.open_urls.count = 0; - for (unsigned cpa = 0; cpa < cocoa_pending_actions_data.closed_notifications.count; cpa++) { - if (cocoa_pending_actions_data.closed_notifications.items[cpa]) { - cocoa_report_closed_notification(cocoa_pending_actions_data.closed_notifications.items[cpa], false); - free(cocoa_pending_actions_data.closed_notifications.items[cpa]); - cocoa_pending_actions_data.closed_notifications.items[cpa] = NULL; - } - } - cocoa_pending_actions_data.closed_notifications.count = 0; - for (unsigned cpa = 0; cpa < cocoa_pending_actions_data.untracked_notifications.count; cpa++) { if (cocoa_pending_actions_data.untracked_notifications.items[cpa]) { - cocoa_report_closed_notification(cocoa_pending_actions_data.untracked_notifications.items[cpa], true); + cocoa_report_live_notifications(cocoa_pending_actions_data.untracked_notifications.items[cpa]); free(cocoa_pending_actions_data.untracked_notifications.items[cpa]); cocoa_pending_actions_data.untracked_notifications.items[cpa] = NULL; } diff --git a/kitty/cocoa_window.h b/kitty/cocoa_window.h index 4420680dc..793a613a2 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -34,7 +34,6 @@ typedef enum { MINIMIZE, QUIT, USER_MENU_ACTION, - COCOA_NOTIFICATION_CLOSED, COCOA_NOTIFICATION_UNTRACKED, NUM_COCOA_PENDING_ACTIONS @@ -60,4 +59,4 @@ bool cocoa_render_line_of_text(const char *text, const color_type fg, const colo extern uint8_t* render_single_ascii_char_as_mask(const char ch, size_t *result_width, size_t *result_height); void get_cocoa_key_equivalent(uint32_t, int, char *key, size_t key_sz, int*); void set_cocoa_pending_action(CocoaPendingAction action, const char*); -void cocoa_report_closed_notification(const char* ident, bool untracked); +void cocoa_report_live_notifications(const char* ident); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index f7e51ce4d..c388ed2be 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -435,7 +435,7 @@ cocoa_send_notification(PyObject *self UNUSED, PyObject *args) { do_notification_callback([[[response notification] request] identifier], "activated"); } else if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) { // this never actually happens on macOS. Bloody Crapple. - do_notification_callback([[[response notification] request] identifier], "closed"); + // do_notification_callback([[[response notification] request] identifier], "closed"); } completionHandler(); } @@ -457,23 +457,6 @@ get_notification_center_safely(void) { return center; } -static bool -remove_delivered_notification(const char *identifier) { - UNUserNotificationCenter *center = get_notification_center_safely(); - if (!center) return false; - [center removeDeliveredNotificationsWithIdentifiers:@[ @(identifier) ]]; - return true; -} - - -static NSLock *notifications_polling_lock = NULL; -static bool polling_notifications = false; -typedef struct tn { char *ident; bool closed; monotonic_t creation_time; } tn; -static struct { tn *items; size_t count, capacity; monotonic_t creation_time; } tracked_notifications; -static void dispatch_closed_notifications(void); -#define CLOSE_POLL_TIME 60. -#define CLOSE_POLL_INTERVAL 750 - static bool ident_in_list_of_notifications(NSString *ident, NSArray *list) { for (UNNotification *n in list) { @@ -482,82 +465,40 @@ ident_in_list_of_notifications(NSString *ident, NSArray *list) return false; } -static void -poll_for_closed_notifications(void) { - UNUserNotificationCenter *center = get_notification_center_safely(); - if (!center) return; - [center getDeliveredNotificationsWithCompletionHandler:^(NSArray * notifications) { - // NSLog(@"num of delivered but not closed nots: %lu", (unsigned long)[notifications count]); - [notifications_polling_lock lock]; - for (size_t i = 0; i < tracked_notifications.count; i++) { - if (!ident_in_list_of_notifications(@(tracked_notifications.items[i].ident), notifications)) tracked_notifications.items[i].closed = true; - } - [notifications_polling_lock unlock]; - dispatch_async(dispatch_get_main_queue(), ^{ - dispatch_closed_notifications(); - }); - }]; -} - void -cocoa_report_closed_notification(const char* ident, bool untracked) { - do_notification_callback(@(ident), untracked ? "untracked" : "closed"); +cocoa_report_live_notifications(const char* ident) { + do_notification_callback(@(ident), "live"); } -static void -dispatch_closed_notifications(void) { - bool poll = false; - [notifications_polling_lock lock]; - monotonic_t now = monotonic(); - for (size_t i = tracked_notifications.count; i-- > 0; ) { - if (tracked_notifications.items[i].closed) { - set_cocoa_pending_action(COCOA_NOTIFICATION_CLOSED, tracked_notifications.items[i].ident); - free(tracked_notifications.items[i].ident); - remove_i_from_array(tracked_notifications.items, i, tracked_notifications.count); - } else { - if (now - tracked_notifications.items[i].creation_time < s_double_to_monotonic_t(CLOSE_POLL_TIME)) { - poll = true; - } else { - set_cocoa_pending_action(COCOA_NOTIFICATION_UNTRACKED, tracked_notifications.items[i].ident); - free(tracked_notifications.items[i].ident); - remove_i_from_array(tracked_notifications.items, i, tracked_notifications.count); - } - } - } - polling_notifications = poll; - [notifications_polling_lock unlock]; - if (poll) { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, CLOSE_POLL_INTERVAL * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{ - poll_for_closed_notifications(); - }); - } -} - -static void -track_notification(char *ident) { +static bool +remove_delivered_notification(const char *identifier) { UNUserNotificationCenter *center = get_notification_center_safely(); - if (center) { - [notifications_polling_lock lock]; - bool has_existing = false; - for (size_t i = 0; i < tracked_notifications.count; i++) { - if (strcmp(tracked_notifications.items[i].ident, ident) == 0) { - tracked_notifications.items[i].creation_time = monotonic(); - has_existing = true; - break; - } + if (!center) return false; + char *ident = strdup(identifier); + [center getDeliveredNotificationsWithCompletionHandler:^(NSArray * notifications) { + if (ident_in_list_of_notifications(@(ident), notifications)) { + [center removeDeliveredNotificationsWithIdentifiers:@[ @(ident) ]]; } - if (!has_existing) { - ensure_space_for(&tracked_notifications, items, tn, tracked_notifications.count + 1, capacity, 8, false); - tracked_notifications.items[tracked_notifications.count++] = (tn){.ident=ident, .creation_time=monotonic()}; - } - bool needs_poll = !polling_notifications; - [notifications_polling_lock unlock]; - if (needs_poll) poll_for_closed_notifications(); - } + free(ident); + }]; + return true; +} + +static bool +live_delivered_notifications(void) { + UNUserNotificationCenter *center = get_notification_center_safely(); + if (!center) return false; + [center getDeliveredNotificationsWithCompletionHandler:^(NSArray * notifications) { + NSMutableString *buffer = [[NSMutableString stringWithCapacity:1024] autorelease]; + for (UNNotification *n in notifications) [buffer appendFormat:@"%@,", [[n request] identifier]]; + const char *val = [buffer UTF8String]; + set_cocoa_pending_action(COCOA_NOTIFICATION_UNTRACKED, val ? val : ""); + }]; + return true; } static void -schedule_notification(const char *identifier, const char *title, const char *body, bool track_closing, 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. @@ -591,9 +532,8 @@ schedule_notification(const char *identifier, const char *title, const char *bod if (error != nil) log_error("Failed to show notification: %s", [[error localizedDescription] UTF8String]); bool ok = error == nil; dispatch_async(dispatch_get_main_queue(), ^{ - do_notification_callback(@(duped_ident), ok ? "created" : "closed"); - if (ok && track_closing) track_notification(duped_ident); - else free(duped_ident); + do_notification_callback(@(duped_ident), ok ? "created" : "creation_failed"); + free(duped_ident); }); }]; [content release]; @@ -602,7 +542,7 @@ schedule_notification(const char *identifier, const char *title, const char *bod typedef struct { char *identifier, *title, *body; - int urgency; bool track_closing; + int urgency; } QueuedNotification; typedef struct { @@ -612,13 +552,13 @@ typedef struct { static NotificationQueue notification_queue = {0}; static void -queue_notification(const char *identifier, const char *title, const char* body, bool track_closing, 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->urgency = urgency; n->track_closing = track_closing; + n->urgency = urgency; } static void @@ -626,12 +566,12 @@ 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->track_closing, 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"); + if (!granted) do_notification_callback(@(n->identifier), "creation_failed"); free(n->identifier); free(n->title); free(n->body); memset(n, 0, sizeof(QueuedNotification)); } @@ -644,16 +584,23 @@ cocoa_remove_delivered_notification(PyObject *self UNUSED, PyObject *x) { Py_RETURN_FALSE; } +static PyObject* +cocoa_live_delivered_notifications(PyObject *self UNUSED, PyObject *x UNUSED) { + if (live_delivered_notifications()) { Py_RETURN_TRUE; } + Py_RETURN_FALSE; +} + + + static PyObject* cocoa_send_notification(PyObject *self UNUSED, PyObject *args) { char *identifier = NULL, *title = NULL, *body = NULL; int urgency = 1; - int track_closing; - if (!PyArg_ParseTuple(args, "sssp|i", &identifier, &title, &body, &track_closing, &urgency)) return NULL; + 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, track_closing, 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. @@ -1123,8 +1070,6 @@ cleanup(void) { dockMenu = nil; if (beep_sound) [beep_sound release]; beep_sound = nil; - if (notifications_polling_lock) [notifications_polling_lock release]; - notifications_polling_lock = nil; #ifndef KITTY_USE_DEPRECATED_MACOS_NOTIFICATION_API drain_pending_notifications(NO); @@ -1164,6 +1109,7 @@ static PyMethodDef module_methods[] = { {"cocoa_set_global_shortcut", (PyCFunction)cocoa_set_global_shortcut, METH_VARARGS, ""}, {"cocoa_send_notification", (PyCFunction)cocoa_send_notification, METH_VARARGS, ""}, {"cocoa_remove_delivered_notification", (PyCFunction)cocoa_remove_delivered_notification, METH_O, ""}, + {"cocoa_live_delivered_notifications", (PyCFunction)cocoa_live_delivered_notifications, METH_NOARGS, ""}, {"cocoa_set_notification_activated_callback", (PyCFunction)set_notification_activated_callback, METH_O, ""}, {"cocoa_set_url_handler", (PyCFunction)cocoa_set_url_handler, METH_VARARGS, ""}, {"cocoa_set_app_icon", (PyCFunction)cocoa_set_app_icon, METH_VARARGS, ""}, @@ -1175,7 +1121,6 @@ bool init_cocoa(PyObject *module) { cocoa_clear_global_shortcuts(); if (PyModule_AddFunctions(module, module_methods) != 0) return false; - notifications_polling_lock = [NSLock new]; register_at_exit_cleanup_func(COCOA_CLEANUP_FUNC, cleanup); return true; } diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 435366e0e..26192985e 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -568,12 +568,12 @@ def cocoa_send_notification( identifier: str, title: str, body: str, - track_closing: bool, urgency: int = 1, ) -> None: pass def cocoa_remove_delivered_notification(identifier: str) -> bool: ... +def cocoa_live_delivered_notifications() -> bool: ... def create_os_window( get_window_size: Callable[[int, int, int, int, float, float], Tuple[int, diff --git a/kitty/notifications.py b/kitty/notifications.py index ecd5a3a89..a875eb365 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -7,7 +7,7 @@ from collections import OrderedDict from contextlib import suppress from enum import Enum from itertools import count -from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Set, Tuple, Union +from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Sequence, Set, Tuple, Union from weakref import ReferenceType, ref from .constants import cache_dir, config_dir, is_macos, logo_png_file @@ -111,6 +111,7 @@ class PayloadType(Enum): query = '?' close = 'close' icon = 'icon' + alive = 'alive' @property def is_text(self) -> bool: @@ -203,7 +204,7 @@ class NotificationCommand: # event callbacks on_activation: Optional[Callable[['NotificationCommand'], None]] = None - on_close: Optional[Callable[['NotificationCommand', bool], None]] = None + on_close: Optional[Callable[['NotificationCommand'], None]] = None on_update: Optional[Callable[['NotificationCommand', 'NotificationCommand'], None]] = None # metadata @@ -410,6 +411,9 @@ class DesktopIntegration: def initialize(self) -> None: pass + def query_live_notifications(self, channel_id: int, identifier: str) -> None: + raise NotImplementedError('Implement me in subclass') + def close_notification(self, desktop_notification_id: int) -> bool: raise NotImplementedError('Implement me in subclass') @@ -432,11 +436,21 @@ class DesktopIntegration: class MacOSIntegration(DesktopIntegration): + supports_close_events: bool = False + def initialize(self) -> None: from .fast_data_types import cocoa_set_notification_activated_callback self.id_counter = count(start=1) + self.live_notification_queries: List[Tuple[int, str]] = [] cocoa_set_notification_activated_callback(self.notification_activated) + def query_live_notifications(self, channel_id: int, identifier: str) -> None: + from .fast_data_types import cocoa_live_delivered_notifications + if not cocoa_live_delivered_notifications(): + self.notification_manager.send_live_response(channel_id, identifier, ()) + else: + self.live_notification_queries.append((channel_id, identifier)) + def close_notification(self, desktop_notification_id: int) -> bool: from .fast_data_types import cocoa_remove_delivered_notification close_succeeded = cocoa_remove_delivered_notification(str(desktop_notification_id)) @@ -455,12 +469,19 @@ class MacOSIntegration(DesktopIntegration): # for %% escaping. body = (nc.body or ' ') assert nc.urgency is not None - cocoa_send_notification(str(desktop_notification_id), nc.title, body, bool(nc.close_response_requested), 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: if debug_desktop_integration: log_error(f'Notification {ident} {event=}') + if event == 'live': + live_ids = tuple(int(x) for x in ident.split(',') if x) + self.notification_manager.purge_dead_notifications(live_ids) + self.live_notification_queries, queries = [], self.live_notification_queries + for channel_id, req_id in queries: + self.notification_manager.send_live_response(channel_id, req_id, live_ids) + return try: desktop_notification_id = int(ident) except Exception: @@ -468,12 +489,12 @@ class MacOSIntegration(DesktopIntegration): return if event == "created": self.notification_manager.notification_created(desktop_notification_id) + from .fast_data_types import cocoa_live_delivered_notifications + cocoa_live_delivered_notifications() # so that we purge dead notifications elif event == "activated": self.notification_manager.notification_activated(desktop_notification_id) - elif event == "closed": + elif event == "creation_failed": self.notification_manager.notification_closed(desktop_notification_id) - elif event == "untracked": - self.notification_manager.notification_closed(desktop_notification_id, True) class FreeDesktopIntegration(DesktopIntegration): @@ -486,6 +507,9 @@ class FreeDesktopIntegration(DesktopIntegration): self.dbus_to_desktop: 'OrderedDict[int, int]' = OrderedDict() self.desktop_to_dbus: Dict[int, int] = {} + def query_live_notifications(self, channel_id: int, identifier: str) -> None: + self.notification_manager.send_live_response(channel_id, identifier, tuple(self.desktop_to_dbus)) + def close_notification(self, desktop_notification_id: int) -> bool: from .fast_data_types import dbus_close_notification close_succeeded = False @@ -672,14 +696,14 @@ class NotificationManager: except Exception as e: self.log('Notification on_update handler failed with error:', e) - def notification_closed(self, desktop_notification_id: int, untracked: bool = False) -> None: + def notification_closed(self, desktop_notification_id: int) -> None: if n := self.in_progress_notification_commands.get(desktop_notification_id): self.purge_notification(n) if n.close_response_requested: - self.send_closed_response(n.channel_id, n.identifier, untracked) + self.send_closed_response(n.channel_id, n.identifier) if n.on_close is not None: try: - n.on_close(n, untracked) + n.on_close(n) except Exception as e: self.log('Notification on_close handler failed with error:', e) @@ -740,6 +764,8 @@ class NotificationManager: self.register_in_progress_notification(cmd, desktop_notification_id) if existing_cmd: self.notification_replaced(existing_cmd, cmd) + if not self.desktop_integration.supports_close_events and cmd.close_response_requested: + self.send_closed_response(channel_id, cmd.identifier, untracked=True) return desktop_notification_id def register_in_progress_notification(self, cmd: NotificationCommand, desktop_notification_id: int) -> None: @@ -764,6 +790,10 @@ class NotificationManager: if payload_type is PayloadType.query: self.channel.send(channel_id, self.desktop_integration.query_response(cmd.identifier)) return None + if payload_type is PayloadType.alive: + if cmd.identifier: + self.desktop_integration.query_live_notifications(channel_id, cmd.identifier) + return None if payload_type is PayloadType.close: if cmd.identifier: to_close = self.in_progress_notification_commands_by_client_id.get(cmd.identifier) @@ -783,7 +813,19 @@ class NotificationManager: def send_closed_response(self, channel_id: int, client_id: str, untracked: bool = False) -> None: payload = 'untracked' if untracked else '' - self.channel.send(channel_id, f'99;i={client_id}:p=close;{payload}') + self.channel.send(channel_id, f'99;i={client_id}:p={PayloadType.close.value};{payload}') + + def send_live_response(self, channel_id: int, client_id: str, live_desktop_ids: Sequence[int]) -> None: + ids = [] + for desktop_notification_id in live_desktop_ids: + if n := self.in_progress_notification_commands.get(desktop_notification_id): + if n.identifier and n.channel_id == channel_id: + ids.append(n.identifier) + self.channel.send(channel_id, f'99;i={client_id}:p={PayloadType.alive.value};{",".join(ids)}') + + def purge_dead_notifications(self, live_desktop_ids: Sequence[int]) -> None: + for d in set(self.in_progress_notification_commands) - set(live_desktop_ids): + self.purge_notification(self.in_progress_notification_commands[d]) def purge_notification(self, cmd: NotificationCommand) -> None: self.in_progress_notification_commands_by_client_id.pop(cmd.identifier, None) diff --git a/kitty_tests/notifications.py b/kitty_tests/notifications.py index 8a4edecd0..d3f2cf24a 100644 --- a/kitty_tests/notifications.py +++ b/kitty_tests/notifications.py @@ -219,7 +219,7 @@ def do_test(self: 'TestNotifications', tdir: str) -> None: # 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:c=1' + qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close,icon,alive:c=1' self.ae(ch.responses, [f'99;i=xyz:p=?;{qr}']) reset() h('p=?')