Get notification buttons working on macOS

This commit is contained in:
Kovid Goyal
2024-08-01 06:40:52 +05:30
parent 1944ac9c13
commit 6a203487a9
3 changed files with 119 additions and 30 deletions

View File

@@ -360,9 +360,10 @@ set_notification_activated_callback(PyObject *self UNUSED, PyObject *callback) {
} }
static void 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) { 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); if (ret) Py_DECREF(ret);
else PyErr_Print(); else PyErr_Print();
} }
@@ -387,12 +388,19 @@ do_notification_callback(NSString *identifier, const char *event) {
didReceiveNotificationResponse:(UNNotificationResponse *)response didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler { withCompletionHandler:(void (^)(void))completionHandler {
(void)(center); (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]) { if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
do_notification_callback([[[response notification] request] identifier], "activated"); event = "activated";
} else if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) { } else if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) {
// this never actually happens on macOS. Bloody Crapple. // Crapple never actually sends this event on macOS
// do_notification_callback([[[response notification] request] identifier], "closed"); event = "closed";
} }
dispatch_async(dispatch_get_main_queue(), ^{
do_notification_callback(identifier, event, action_identifier);
free(identifier); free(action_identifier);
});
completionHandler(); completionHandler();
} }
@end @end
@@ -423,7 +431,7 @@ ident_in_list_of_notifications(NSString *ident, NSArray<UNNotification*> *list)
void void
cocoa_report_live_notifications(const char* ident) { cocoa_report_live_notifications(const char* ident) {
do_notification_callback(@(ident), "live"); do_notification_callback(ident, "live", "");
} }
static bool static bool
@@ -456,7 +464,7 @@ live_delivered_notifications(void) {
} }
static void 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(); UNUserNotificationCenter *center = get_notification_center_safely();
if (!center) return; if (!center) return;
// Configure the notification's payload. // Configure the notification's payload.
@@ -464,6 +472,7 @@ schedule_notification(const char *appname, const char *identifier, const char *t
if (title) content.title = @(title); if (title) content.title = @(title);
if (body) content.body = @(body); if (body) content.body = @(body);
if (appname) content.threadIdentifier = @(appname); if (appname) content.threadIdentifier = @(appname);
if (category_id) content.categoryIdentifier = @(category_id);
content.sound = [UNNotificationSound defaultSound]; content.sound = [UNNotificationSound defaultSound];
#if __MAC_OS_X_VERSION_MIN_REQUIRED >= 120000 #if __MAC_OS_X_VERSION_MIN_REQUIRED >= 120000
switch (urgency) { switch (urgency) {
@@ -503,7 +512,7 @@ schedule_notification(const char *appname, const char *identifier, const char *t
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; bool ok = error == nil;
dispatch_async(dispatch_get_main_queue(), ^{ 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); free(duped_ident);
}); });
}]; }];
@@ -511,7 +520,7 @@ schedule_notification(const char *appname, const char *identifier, const char *t
typedef struct { typedef struct {
char *identifier, *title, *body, *appname, *image_path; char *identifier, *title, *body, *appname, *image_path, *category_id;
int urgency; int urgency;
} QueuedNotification; } QueuedNotification;
@@ -522,11 +531,11 @@ typedef struct {
static NotificationQueue notification_queue = {0}; static NotificationQueue notification_queue = {0};
static void 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((&notification_queue), notifications, QueuedNotification, notification_queue.count + 16, capacity, 16, true); ensure_space_for((&notification_queue), notifications, QueuedNotification, notification_queue.count + 16, capacity, 16, true);
QueuedNotification *n = notification_queue.notifications + notification_queue.count++; QueuedNotification *n = notification_queue.notifications + notification_queue.count++;
#define d(x) n->x = (x && x[0]) ? strdup(x) : NULL; #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 #undef d
n->urgency = urgency; n->urgency = urgency;
} }
@@ -536,13 +545,13 @@ drain_pending_notifications(BOOL granted) {
if (granted) { if (granted) {
for (size_t i = 0; i < notification_queue.count; i++) { for (size_t i = 0; i < notification_queue.count; i++) {
QueuedNotification *n = notification_queue.notifications + 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) { while(notification_queue.count) {
QueuedNotification *n = notification_queue.notifications + --notification_queue.count; QueuedNotification *n = notification_queue.notifications + --notification_queue.count;
if (!granted) do_notification_callback(@(n->identifier), "creation_failed"); 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->identifier); free(n->title); free(n->body); free(n->appname); free(n->image_path); free(n->category_id);
memset(n, 0, sizeof(QueuedNotification)); memset(n, 0, sizeof(QueuedNotification));
} }
} }
@@ -560,18 +569,46 @@ cocoa_live_delivered_notifications(PyObject *self UNUSED, PyObject *x UNUSED) {
Py_RETURN_FALSE; 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<UNNotificationAction *> *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<UNNotificationCategory *> *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* static PyObject*
cocoa_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { cocoa_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) {
const char *identifier = "", *title = "", *body = "", *appname = "", *image_path = ""; int urgency = 1; const char *identifier = "", *title = "", *body = "", *appname = "", *image_path = ""; int urgency = 1;
static const char* kwlist[] = {"appname", "identifier", "title", "body", "image_path", "urgency", NULL}; PyObject *category, *categories;
if (!PyArg_ParseTupleAndKeywords(args, kw, "ssss|si", (char**)kwlist, &appname, &identifier, &title, &body, &image_path, &urgency)) return NULL; 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(); UNUserNotificationCenter *center = get_notification_center_safely();
if (!center) Py_RETURN_NONE; if (!center) Py_RETURN_NONE;
if (!center.delegate) center.delegate = [[NotificationDelegate alloc] init]; 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, // 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. // otherwise macOS refuses to show the preference checkbox for enable/disable notification sound.

View File

@@ -6,6 +6,7 @@ from kitty.boss import Boss
from kitty.fonts import VariableData from kitty.fonts import VariableData
from kitty.fonts.render import FontObject from kitty.fonts.render import FontObject
from kitty.marks import MarkerFunc from kitty.marks import MarkerFunc
from kitty.notifications import MacOSNotificationCategory
from kitty.options.types import Options from kitty.options.types import Options
from kitty.types import LayerShellConfig, SignalInfo from kitty.types import LayerShellConfig, SignalInfo
from kitty.typing import EdgeLiteral, NotRequired, ReadableBuffer, WriteableBuffer from kitty.typing import EdgeLiteral, NotRequired, ReadableBuffer, WriteableBuffer
@@ -566,6 +567,8 @@ def cocoa_send_notification(
identifier: str, identifier: str,
title: str, title: str,
body: str, body: str,
category: MacOSNotificationCategory,
categories: tuple[MacOSNotificationCategory, ...],
image_path: str = "", image_path: str = "",
urgency: int = 1, urgency: int = 1,
) -> None: ) -> None:
@@ -842,7 +845,7 @@ def os_window_font_size(
pass 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 pass

View File

@@ -469,6 +469,12 @@ class DesktopIntegration:
return f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}{c}:w=1' 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): class MacOSIntegration(DesktopIntegration):
supports_close_events: bool = False supports_close_events: bool = False
@@ -479,6 +485,11 @@ class MacOSIntegration(DesktopIntegration):
self.live_notification_queries: list[tuple[int, str]] = [] self.live_notification_queries: list[tuple[int, str]] = []
self.failed_icons: OrderedDict[str, bool] = OrderedDict() self.failed_icons: OrderedDict[str, bool] = OrderedDict()
self.icd_key_prefix = os.urandom(16).hex() 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) cocoa_set_notification_activated_callback(self.notification_activated)
def query_live_notifications(self, channel_id: int, identifier: str) -> None: def query_live_notifications(self, channel_id: int, identifier: str) -> None:
@@ -520,6 +531,21 @@ class MacOSIntegration(DesktopIntegration):
return icd.add_icon(icd_key, data) return icd.add_icon(icd_key, data)
return '' 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: def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Optional[int]) -> int:
desktop_notification_id = existing_desktop_notification_id or next(self.id_counter) desktop_notification_id = existing_desktop_notification_id or next(self.id_counter)
from .fast_data_types import cocoa_send_notification from .fast_data_types import cocoa_send_notification
@@ -537,17 +563,22 @@ class MacOSIntegration(DesktopIntegration):
image_path = image_path or nc.icon_path image_path = image_path or nc.icon_path
if not image_path and nc.application_name: if not image_path and nc.application_name:
image_path = self.get_icon_for_name(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( cocoa_send_notification(
nc.application_name or 'kitty', str(desktop_notification_id), nc.title, body, 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 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 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) live_ids = tuple(int(x) for x in ident.split(',') if x)
self.notification_manager.purge_dead_notifications(live_ids) self.notification_manager.purge_dead_notifications(live_ids)
self.live_notification_queries, queries = [], self.live_notification_queries self.live_notification_queries, queries = [], self.live_notification_queries
@@ -555,20 +586,36 @@ class MacOSIntegration(DesktopIntegration):
self.notification_manager.send_live_response(channel_id, req_id, live_ids) self.notification_manager.send_live_response(channel_id, req_id, live_ids)
return return
if debug_desktop_integration: if debug_desktop_integration:
log_error(f'Notification {ident} {event=}') log_error(f'Notification {ident=} {event=} {button_id=}')
try: try:
desktop_notification_id = int(ident) desktop_notification_id = int(ident)
except Exception: except Exception:
log_error(f'Got unexpected notification activated event with id: {ident!r} from cocoa') log_error(f'Got unexpected notification activated event with id: {ident!r} from cocoa')
return return
if event == "created": if event == 'created':
self.notification_manager.notification_created(desktop_notification_id) self.notification_manager.notification_created(desktop_notification_id)
from .fast_data_types import cocoa_live_delivered_notifications from .fast_data_types import cocoa_live_delivered_notifications
cocoa_live_delivered_notifications() # so that we purge dead 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) 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) 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): class FreeDesktopIntegration(DesktopIntegration):
@@ -772,17 +819,17 @@ class NotificationManager:
if n := self.in_progress_notification_commands.get(desktop_notification_id): if n := self.in_progress_notification_commands.get(desktop_notification_id):
n.activation_token = token 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 n := self.in_progress_notification_commands.get(desktop_notification_id):
if not n.close_response_requested: if not n.close_response_requested:
self.purge_notification(n) self.purge_notification(n)
if n.focus_requested: if n.focus_requested:
self.channel.focus(n.channel_id, n.activation_token) self.channel.focus(n.channel_id, n.activation_token)
if n.report_requested: 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: if n.on_activation:
try: try:
n.on_activation(n, which) n.on_activation(n, button)
except Exception as e: except Exception as e:
self.log('Notification on_activation handler failed with error:', e) self.log('Notification on_activation handler failed with error:', e)
@@ -798,7 +845,7 @@ class NotificationManager:
def notification_closed(self, desktop_notification_id: int) -> None: def notification_closed(self, desktop_notification_id: int) -> None:
if n := self.in_progress_notification_commands.get(desktop_notification_id): if n := self.in_progress_notification_commands.get(desktop_notification_id):
self.purge_notification(n) 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) self.send_closed_response(n.channel_id, n.identifier)
if n.on_close is not None: if n.on_close is not None:
try: try:
@@ -929,6 +976,8 @@ class NotificationManager:
def purge_dead_notifications(self, live_desktop_ids: Sequence[int]) -> None: def purge_dead_notifications(self, live_desktop_ids: Sequence[int]) -> None:
for d in set(self.in_progress_notification_commands) - set(live_desktop_ids): 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]) self.purge_notification(self.in_progress_notification_commands[d])
def purge_notification(self, cmd: NotificationCommand) -> None: def purge_notification(self, cmd: NotificationCommand) -> None: