From 6a203487a9ea963124ef671e3aaa8a2f64abbc17 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 1 Aug 2024 06:40:52 +0530 Subject: [PATCH] Get notification buttons working on macOS --- kitty/cocoa_window.m | 71 ++++++++++++++++++++++++++++--------- kitty/fast_data_types.pyi | 5 ++- kitty/notifications.py | 73 ++++++++++++++++++++++++++++++++------- 3 files changed, 119 insertions(+), 30 deletions(-) diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 3c203f725..f59c0c063 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -360,9 +360,10 @@ + (GlobalMenuTarget *) shared_instance } static void -do_notification_callback(NSString *identifier, const char *event) { +do_notification_callback(const char *identifier, const char *event, const char *action_identifer) { if (notification_activated_callback) { - PyObject *ret = PyObject_CallFunction(notification_activated_callback, "sz", event, (identifier ? [identifier UTF8String] : NULL)); + PyObject *ret = PyObject_CallFunction(notification_activated_callback, "sss", event, + identifier ? identifier : "", action_identifer ? action_identifer : ""); if (ret) Py_DECREF(ret); else PyErr_Print(); } @@ -387,12 +388,19 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { (void)(center); + char *identifier = strdup(response.notification.request.identifier.UTF8String); + char *action_identifier = strdup(response.actionIdentifier.UTF8String); + const char *event = "button"; if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) { - do_notification_callback([[[response notification] request] identifier], "activated"); + event = "activated"; } else if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) { - // this never actually happens on macOS. Bloody Crapple. - // do_notification_callback([[[response notification] request] identifier], "closed"); + // Crapple never actually sends this event on macOS + event = "closed"; } + dispatch_async(dispatch_get_main_queue(), ^{ + do_notification_callback(identifier, event, action_identifier); + free(identifier); free(action_identifier); + }); completionHandler(); } @end @@ -423,7 +431,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center void cocoa_report_live_notifications(const char* ident) { - do_notification_callback(@(ident), "live"); + do_notification_callback(ident, "live", ""); } static bool @@ -456,7 +464,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center } static void -schedule_notification(const char *appname, const char *identifier, const char *title, const char *body, const char *image_path, int urgency) {@autoreleasepool { +schedule_notification(const char *appname, const char *identifier, const char *title, const char *body, const char *image_path, int urgency, const char *category_id) {@autoreleasepool { UNUserNotificationCenter *center = get_notification_center_safely(); if (!center) return; // Configure the notification's payload. @@ -464,6 +472,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center if (title) content.title = @(title); if (body) content.body = @(body); if (appname) content.threadIdentifier = @(appname); + if (category_id) content.categoryIdentifier = @(category_id); content.sound = [UNNotificationSound defaultSound]; #if __MAC_OS_X_VERSION_MIN_REQUIRED >= 120000 switch (urgency) { @@ -503,7 +512,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center 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" : "creation_failed"); + do_notification_callback(duped_ident, ok ? "created" : "creation_failed", ""); free(duped_ident); }); }]; @@ -511,7 +520,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center typedef struct { - char *identifier, *title, *body, *appname, *image_path; + char *identifier, *title, *body, *appname, *image_path, *category_id; int urgency; } QueuedNotification; @@ -522,11 +531,11 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center static NotificationQueue notification_queue = {0}; static void -queue_notification(const char *appname, const char *identifier, const char *title, const char* body, const char *image_path, int urgency) { +queue_notification(const char *appname, const char *identifier, const char *title, const char* body, const char *image_path, int urgency, const char *category_id) { ensure_space_for((¬ification_queue), notifications, QueuedNotification, notification_queue.count + 16, capacity, 16, true); QueuedNotification *n = notification_queue.notifications + notification_queue.count++; #define d(x) n->x = (x && x[0]) ? strdup(x) : NULL; - d(appname); d(identifier); d(title); d(body); d(image_path); + d(appname); d(identifier); d(title); d(body); d(image_path); d(category_id); #undef d n->urgency = urgency; } @@ -536,13 +545,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->appname, n->identifier, n->title, n->body, n->image_path, n->urgency); + schedule_notification(n->appname, n->identifier, n->title, n->body, n->image_path, n->urgency, n->category_id); } } while(notification_queue.count) { QueuedNotification *n = notification_queue.notifications + --notification_queue.count; - if (!granted) do_notification_callback(@(n->identifier), "creation_failed"); - free(n->identifier); free(n->title); free(n->body); free(n->appname); free(n->image_path); + if (!granted) do_notification_callback(n->identifier, "creation_failed", ""); + free(n->identifier); free(n->title); free(n->body); free(n->appname); free(n->image_path); free(n->category_id); memset(n, 0, sizeof(QueuedNotification)); } } @@ -560,18 +569,46 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center Py_RETURN_FALSE; } +static UNNotificationCategory* +category_from_python(PyObject *p) { + RAII_PyObject(button_ids, PyObject_GetAttrString(p, "button_ids")); + RAII_PyObject(buttons, PyObject_GetAttrString(p, "buttons")); + RAII_PyObject(id, PyObject_GetAttrString(p, "id")); + NSMutableArray *actions = [NSMutableArray arrayWithCapacity:PyTuple_GET_SIZE(buttons)]; + for (int i = 0; i < PyTuple_GET_SIZE(buttons); i++) [actions addObject: + [UNNotificationAction actionWithIdentifier:@(PyUnicode_AsUTF8(PyTuple_GET_ITEM(button_ids, i))) + title:@(PyUnicode_AsUTF8(PyTuple_GET_ITEM(buttons, i))) options:UNNotificationActionOptionNone]]; + return [UNNotificationCategory categoryWithIdentifier:@(PyUnicode_AsUTF8(id)) + actions:actions intentIdentifiers:@[] options:0]; +} + +static bool +set_notification_categories(UNUserNotificationCenter *center, PyObject *categories) { + NSMutableArray *ans = [NSMutableArray arrayWithCapacity:PyTuple_GET_SIZE(categories)]; + for (int i = 0; i < PyTuple_GET_SIZE(categories); i++) { + UNNotificationCategory *c = category_from_python(PyTuple_GET_ITEM(categories, i)); + if (!c) return false; + [ans addObject:c]; + } + [center setNotificationCategories:[NSSet setWithArray:ans]]; + return true; +} static PyObject* cocoa_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { const char *identifier = "", *title = "", *body = "", *appname = "", *image_path = ""; int urgency = 1; - static const char* kwlist[] = {"appname", "identifier", "title", "body", "image_path", "urgency", NULL}; - if (!PyArg_ParseTupleAndKeywords(args, kw, "ssss|si", (char**)kwlist, &appname, &identifier, &title, &body, &image_path, &urgency)) return NULL; + PyObject *category, *categories; + static const char* kwlist[] = {"appname", "identifier", "title", "body", "category", "categories", "image_path", "urgency", NULL}; + if (!PyArg_ParseTupleAndKeywords(args, kw, "ssssOO!|si", (char**)kwlist, + &appname, &identifier, &title, &body, &category, &PyTuple_Type, &categories, &image_path, &urgency)) return NULL; UNUserNotificationCenter *center = get_notification_center_safely(); if (!center) Py_RETURN_NONE; if (!center.delegate) center.delegate = [[NotificationDelegate alloc] init]; - queue_notification(appname, identifier, title, body, image_path, urgency); + if (PyObject_IsTrue(categories)) if (!set_notification_categories(center, categories)) return NULL; + RAII_PyObject(category_id, PyObject_GetAttrString(category, "id")); + queue_notification(appname, identifier, title, body, image_path, urgency, PyUnicode_AsUTF8(category_id)); // 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 1d69654f8..e3566cda0 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -6,6 +6,7 @@ from kitty.boss import Boss from kitty.fonts import VariableData from kitty.fonts.render import FontObject from kitty.marks import MarkerFunc +from kitty.notifications import MacOSNotificationCategory from kitty.options.types import Options from kitty.types import LayerShellConfig, SignalInfo from kitty.typing import EdgeLiteral, NotRequired, ReadableBuffer, WriteableBuffer @@ -566,6 +567,8 @@ def cocoa_send_notification( identifier: str, title: str, body: str, + category: MacOSNotificationCategory, + categories: tuple[MacOSNotificationCategory, ...], image_path: str = "", urgency: int = 1, ) -> None: @@ -842,7 +845,7 @@ def os_window_font_size( pass -def cocoa_set_notification_activated_callback(identifier: Optional[Callable[[str, str], None]]) -> None: +def cocoa_set_notification_activated_callback(identifier: Optional[Callable[[str, str, str], None]]) -> None: pass diff --git a/kitty/notifications.py b/kitty/notifications.py index f7f5c4d14..f338e7075 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -469,6 +469,12 @@ def query_response(self, identifier: str) -> str: return f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}{c}:w=1' +class MacOSNotificationCategory(NamedTuple): + id: str + buttons: tuple[str, ...] = () + button_ids: tuple[str, ...] = () + + class MacOSIntegration(DesktopIntegration): supports_close_events: bool = False @@ -479,6 +485,11 @@ def initialize(self) -> None: self.live_notification_queries: list[tuple[int, str]] = [] self.failed_icons: OrderedDict[str, bool] = OrderedDict() self.icd_key_prefix = os.urandom(16).hex() + self.category_cache: OrderedDict[tuple[str, ...], MacOSNotificationCategory] = OrderedDict() + self.category_id_counter = count(start=2) + self.buttons_id_counter = count(start=1) + self.default_category = MacOSNotificationCategory('1') + self.current_categories: frozenset[MacOSNotificationCategory] = frozenset() cocoa_set_notification_activated_callback(self.notification_activated) def query_live_notifications(self, channel_id: int, identifier: str) -> None: @@ -520,6 +531,21 @@ def get_icon_for_name(self, name: str) -> str: return icd.add_icon(icd_key, data) return '' + def category_for_notification(self, nc: NotificationCommand) -> MacOSNotificationCategory: + key = nc.buttons + if not key: + return self.default_category + if ans := self.category_cache.get(key): + self.category_cache.pop(key) + self.category_cache[key] = ans + return ans + ans = self.category_cache[key] = MacOSNotificationCategory( + str(next(self.category_id_counter)), nc.buttons, tuple(str(next(self.buttons_id_counter)) for x in nc.buttons) + ) + if len(self.category_cache) > 32: + self.category_cache.popitem(False) + return ans + 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 @@ -537,17 +563,22 @@ def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Opti image_path = image_path or nc.icon_path if not image_path and nc.application_name: image_path = self.get_icon_for_name(nc.application_name) + category = self.category_for_notification(nc) + categories = tuple(self.category_cache.values()) + sc = frozenset(categories) + if sc == self.current_categories: + categories = () + else: + self.current_categories = sc cocoa_send_notification( nc.application_name or 'kitty', str(desktop_notification_id), nc.title, body, - image_path=image_path, urgency=nc.urgency.value, + category=category, categories=categories, image_path=image_path, urgency=nc.urgency.value, ) return desktop_notification_id - def notification_activated(self, event: str, ident: str) -> None: + def notification_activated(self, event: str, ident: str, button_id: str) -> None: if event == 'live': - if debug_desktop_integration: - log_error('Got list of live notifications:', ident) 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 @@ -555,20 +586,36 @@ def notification_activated(self, event: str, ident: str) -> None: self.notification_manager.send_live_response(channel_id, req_id, live_ids) return if debug_desktop_integration: - log_error(f'Notification {ident} {event=}') + log_error(f'Notification {ident=} {event=} {button_id=}') try: desktop_notification_id = int(ident) except Exception: log_error(f'Got unexpected notification activated event with id: {ident!r} from cocoa') return - if event == "created": + 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": + elif event == 'activated': self.notification_manager.notification_activated(desktop_notification_id, 0) - elif event == "creation_failed": + elif event == 'creation_failed': self.notification_manager.notification_closed(desktop_notification_id) + elif event == 'closed': # sadly Crapple never delivers these events + self.notification_manager.notification_closed(desktop_notification_id) + elif event == 'button': + if n := self.notification_manager.in_progress_notification_commands.get(desktop_notification_id): + if debug_desktop_integration: + log_error('Button matches notification:', n) + for c in self.current_categories: + if c.buttons == n.buttons and button_id in c.button_ids: + if debug_desktop_integration: + log_error('Button number:', c.button_ids.index(button_id) + 1) + self.notification_manager.notification_activated(desktop_notification_id, c.button_ids.index(button_id) + 1) + break + else: + if debug_desktop_integration: + log_error('No category found with buttons:', n.buttons) + log_error('Current categories:', self.current_categories) class FreeDesktopIntegration(DesktopIntegration): @@ -772,17 +819,17 @@ def notification_activation_token_received(self, desktop_notification_id: int, t if n := self.in_progress_notification_commands.get(desktop_notification_id): n.activation_token = token - def notification_activated(self, desktop_notification_id: int, which: int) -> None: + def notification_activated(self, desktop_notification_id: int, button: int) -> None: if n := self.in_progress_notification_commands.get(desktop_notification_id): if not n.close_response_requested: self.purge_notification(n) if n.focus_requested: self.channel.focus(n.channel_id, n.activation_token) if n.report_requested: - self.channel.send(n.channel_id, f'99;i={n.identifier or "0"};{which or ""}') + self.channel.send(n.channel_id, f'99;i={n.identifier or "0"};{button or ""}') if n.on_activation: try: - n.on_activation(n, which) + n.on_activation(n, button) except Exception as e: self.log('Notification on_activation handler failed with error:', e) @@ -798,7 +845,7 @@ def notification_replaced(self, old_cmd: NotificationCommand, new_cmd: Notificat 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: + if n.close_response_requested and self.desktop_integration.supports_close_events: self.send_closed_response(n.channel_id, n.identifier) if n.on_close is not None: try: @@ -929,6 +976,8 @@ def send_live_response(self, channel_id: int, client_id: str, live_desktop_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): + if debug_desktop_integration: + log_error(f'Purging dead notification {d} from list of live notifications:', live_desktop_ids) self.purge_notification(self.in_progress_notification_commands[d]) def purge_notification(self, cmd: NotificationCommand) -> None: