diff --git a/docs/desktop-notifications.rst b/docs/desktop-notifications.rst index eacdb0a32..a6ba597d6 100644 --- a/docs/desktop-notifications.rst +++ b/docs/desktop-notifications.rst @@ -131,13 +131,15 @@ then both the activation report and close notification are sent. If the notifica 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 - via polling. It is up to the terminal emulator to decide a reasonable - time limit for how long to poll, before giving up. kitty polls for 60 - seconds. Therefore, terminal applications should not rely on close events - being authoritative. +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:: + 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. Updating or closing an existing notification ---------------------------------------------- diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index 8afee1695..6d67d0ac1 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; + cocoa_list open_urls, closed_notifications, untracked_notifications; } CocoaPendingActionsData; static CocoaPendingActionsData cocoa_pending_actions_data = {0}; @@ -1166,6 +1166,7 @@ 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); } void @@ -1176,6 +1177,8 @@ set_cocoa_pending_action(CocoaPendingAction action, const char *data) { 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: if (cocoa_pending_actions_data.wd) free(cocoa_pending_actions_data.wd); cocoa_pending_actions_data.wd = strdup(data); @@ -1226,14 +1229,26 @@ 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]); + 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); + free(cocoa_pending_actions_data.untracked_notifications.items[cpa]); + cocoa_pending_actions_data.untracked_notifications.items[cpa] = NULL; + } + } + cocoa_pending_actions_data.untracked_notifications.count = 0; + + memset(cocoa_pending_actions, 0, sizeof(cocoa_pending_actions)); has_cocoa_pending_actions = false; diff --git a/kitty/cocoa_window.h b/kitty/cocoa_window.h index 6d8cdb918..4420680dc 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -35,6 +35,7 @@ typedef enum { QUIT, USER_MENU_ACTION, COCOA_NOTIFICATION_CLOSED, + COCOA_NOTIFICATION_UNTRACKED, NUM_COCOA_PENDING_ACTIONS } CocoaPendingAction; @@ -59,4 +60,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); +void cocoa_report_closed_notification(const char* ident, bool untracked); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index e5648e83b..f7e51ce4d 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -500,8 +500,8 @@ poll_for_closed_notifications(void) { } void -cocoa_report_closed_notification(const char* ident) { - do_notification_callback(@(ident), "closed"); +cocoa_report_closed_notification(const char* ident, bool untracked) { + do_notification_callback(@(ident), untracked ? "untracked" : "closed"); } static void @@ -514,7 +514,15 @@ dispatch_closed_notifications(void) { 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 { + 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]; @@ -549,7 +557,7 @@ track_notification(char *ident) { } static void -schedule_notification(const char *identifier, const char *title, const char *body, int urgency) { +schedule_notification(const char *identifier, const char *title, const char *body, bool track_closing, int urgency) { UNUserNotificationCenter *center = get_notification_center_safely(); if (!center) return; // Configure the notification's payload. @@ -584,7 +592,7 @@ schedule_notification(const char *identifier, const char *title, const char *bod bool ok = error == nil; dispatch_async(dispatch_get_main_queue(), ^{ do_notification_callback(@(duped_ident), ok ? "created" : "closed"); - if (ok) track_notification(duped_ident); + if (ok && track_closing) track_notification(duped_ident); else free(duped_ident); }); }]; @@ -594,7 +602,7 @@ schedule_notification(const char *identifier, const char *title, const char *bod typedef struct { char *identifier, *title, *body; - int urgency; + int urgency; bool track_closing; } QueuedNotification; typedef struct { @@ -604,13 +612,13 @@ typedef struct { static NotificationQueue notification_queue = {0}; static void -queue_notification(const char *identifier, const char *title, const char* body, int urgency) { +queue_notification(const char *identifier, const char *title, const char* body, bool track_closing, 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->urgency = urgency; n->track_closing = track_closing; } static void @@ -618,7 +626,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->identifier, n->title, n->body, n->urgency); + schedule_notification(n->identifier, n->title, n->body, n->track_closing, n->urgency); } } while(notification_queue.count) { @@ -639,12 +647,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; int urgency = 1; - if (!PyArg_ParseTuple(args, "sss|i", &identifier, &title, &body, &urgency)) return NULL; + int track_closing; + if (!PyArg_ParseTuple(args, "sssp|i", &identifier, &title, &body, &track_closing, &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, urgency); + queue_notification(identifier, title, body, track_closing, 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 0090358f3..435366e0e 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -568,6 +568,7 @@ def cocoa_send_notification( identifier: str, title: str, body: str, + track_closing: bool, urgency: int = 1, ) -> None: pass diff --git a/kitty/notifications.py b/kitty/notifications.py index b9b01d930..ecd5a3a89 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -203,7 +203,7 @@ class NotificationCommand: # event callbacks on_activation: Optional[Callable[['NotificationCommand'], None]] = None - on_close: Optional[Callable[['NotificationCommand'], None]] = None + on_close: Optional[Callable[['NotificationCommand', bool], None]] = None on_update: Optional[Callable[['NotificationCommand', 'NotificationCommand'], None]] = None # metadata @@ -378,6 +378,7 @@ class NotificationCommand: self.title = sanitize_text(self.body) self.body = '' self.urgency = Urgency.Normal if self.urgency is None else self.urgency + self.close_response_requested = bool(self.close_response_requested) def matches_rule_item(self, location:str, query:str) -> bool: import re @@ -454,7 +455,7 @@ 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, nc.urgency.value) + cocoa_send_notification(str(desktop_notification_id), nc.title, body, bool(nc.close_response_requested), nc.urgency.value) return desktop_notification_id def notification_activated(self, event: str, ident: str) -> None: @@ -471,6 +472,8 @@ class MacOSIntegration(DesktopIntegration): self.notification_manager.notification_activated(desktop_notification_id) elif event == "closed": self.notification_manager.notification_closed(desktop_notification_id) + elif event == "untracked": + self.notification_manager.notification_closed(desktop_notification_id, True) class FreeDesktopIntegration(DesktopIntegration): @@ -669,14 +672,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) -> None: + def notification_closed(self, desktop_notification_id: int, untracked: bool = False) -> 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) + self.send_closed_response(n.channel_id, n.identifier, untracked) if n.on_close is not None: try: - n.on_close(n) + n.on_close(n, untracked) except Exception as e: self.log('Notification on_close handler failed with error:', e) @@ -778,8 +781,9 @@ class NotificationManager: cmd.set_payload(payload_type, payload_is_encoded, payload, prev_cmd) return cmd - def send_closed_response(self, channel_id: int, client_id: str) -> None: - self.channel.send(channel_id, f'99;i={client_id}:p=close;') + 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}') def purge_notification(self, cmd: NotificationCommand) -> None: self.in_progress_notification_commands_by_client_id.pop(cmd.identifier, None)