diff --git a/docs/desktop-notifications.rst b/docs/desktop-notifications.rst index e45f47f57..29450187c 100644 --- a/docs/desktop-notifications.rst +++ b/docs/desktop-notifications.rst @@ -116,11 +116,12 @@ 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. -.. note:: - Close events are best effort, some platforms such as macOS do not have - events when notifications are closed. Applications can use the - :ref:`notifications_query` to check if close events are supported - by the current terminal emulator. +.. 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. Closing an existing notification @@ -205,7 +206,8 @@ Key Value Default Description ``-`` ``d`` ``0`` or ``1`` ``1`` Indicates if the notification is - complete or not. + complete or not. A non-zero value + means it is complete. ``e`` ``0`` or ``1`` ``0`` If set to ``1`` means the payload is :rfc:`base64 <4648>` encoded UTF-8, otherwise it is plain UTF-8 text with no C0 control codes in it diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index d22322744..8afee1695 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, delivered_notifications, live_notifications; + cocoa_list open_urls, closed_notifications; } CocoaPendingActionsData; static CocoaPendingActionsData cocoa_pending_actions_data = {0}; @@ -1165,18 +1165,21 @@ 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.delivered_notifications); - cocoa_free_pending_list(&cocoa_pending_actions_data.live_notifications); + cocoa_free_pending_list(&cocoa_pending_actions_data.closed_notifications); } void set_cocoa_pending_action(CocoaPendingAction action, const char *data) { if (data) { - if (action == LAUNCH_URLS) { - cocoa_append_to_pending_list(&cocoa_pending_actions_data.open_urls, data); - } else { - if (cocoa_pending_actions_data.wd) free(cocoa_pending_actions_data.wd); - cocoa_pending_actions_data.wd = strdup(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; + default: + if (cocoa_pending_actions_data.wd) free(cocoa_pending_actions_data.wd); + cocoa_pending_actions_data.wd = strdup(data); + break; } } cocoa_pending_actions[action] = true; @@ -1223,6 +1226,14 @@ 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]); + 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; 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 5e8fb13d2..6d8cdb918 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -34,6 +34,7 @@ typedef enum { MINIMIZE, QUIT, USER_MENU_ACTION, + COCOA_NOTIFICATION_CLOSED, NUM_COCOA_PENDING_ACTIONS } CocoaPendingAction; @@ -58,3 +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); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 25092d4ab..4041729d8 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -354,10 +354,7 @@ static PyObject *notification_activated_callback = NULL; static PyObject* set_notification_activated_callback(PyObject *self UNUSED, PyObject *callback) { Py_CLEAR(notification_activated_callback); - if (callback != Py_None) { - notification_activated_callback = callback; - Py_INCREF(callback); - } + if (callback != Py_None) notification_activated_callback = Py_NewRef(callback); Py_RETURN_NONE; } @@ -468,6 +465,89 @@ remove_delivered_notification(const char *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) { + if ([[[n request] identifier] isEqualToString:ident]) return true; + } + 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) { + do_notification_callback(@(ident), "closed"); +} + +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; + } + 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) { + 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 (!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(); + } +} + static void schedule_notification(const char *identifier, const char *title, const char *body, const char *subtitle, int urgency) { UNUserNotificationCenter *center = get_notification_center_safely(); @@ -501,13 +581,12 @@ schedule_notification(const char *identifier, const char *title, const char *bod content:content trigger:nil]; char *duped_ident = strdup(identifier ? identifier : ""); [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) { - if (error != nil) { - log_error("Failed to show notification: %s", [[error localizedDescription] UTF8String]); - } + 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"); - free(duped_ident); + if (ok) track_notification(duped_ident); + else free(duped_ident); }); }]; [content release]; @@ -1037,6 +1116,8 @@ 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); @@ -1087,6 +1168,7 @@ 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/notifications.py b/kitty/notifications.py index da1fc3b6d..fecd5802b 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -390,8 +390,6 @@ 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)