mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-08 22:28:24 +02:00
Get notification buttons working on macOS
This commit is contained in:
@@ -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((¬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 @@ 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user