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
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
3 changed files with 119 additions and 30 deletions

View file

@ -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((&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 @@ - (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<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 @@ 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: