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
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 @@ do_notification_callback(NSString *identifier, const char *event) {
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 @@ ident_in_list_of_notifications(NSString *ident, NSArray<UNNotification*> *list)
void
cocoa_report_live_notifications(const char* ident) {
do_notification_callback(@(ident), "live");
do_notification_callback(ident, "live", "");
}
static bool
@@ -456,7 +464,7 @@ live_delivered_notifications(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();
if (!center) return;
// 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 (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 @@ schedule_notification(const char *appname, const char *identifier, const char *t
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 @@ schedule_notification(const char *appname, const char *identifier, const char *t
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 @@ typedef struct {
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((&notification_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 @@ 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->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 @@ cocoa_live_delivered_notifications(PyObject *self UNUSED, PyObject *x UNUSED) {
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*
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.

View File

@@ -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

View File

@@ -469,6 +469,12 @@ class DesktopIntegration:
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 @@ class MacOSIntegration(DesktopIntegration):
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 @@ class MacOSIntegration(DesktopIntegration):
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 @@ class MacOSIntegration(DesktopIntegration):
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 @@ class MacOSIntegration(DesktopIntegration):
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 @@ class NotificationManager:
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 @@ class NotificationManager:
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 @@ class NotificationManager:
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: