Implement a polling mode for close events

This commit is contained in:
Kovid Goyal 2024-07-28 15:35:22 +05:30
parent 02bc104091
commit 083f158bbd
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
7 changed files with 115 additions and 133 deletions

View file

@ -132,14 +132,22 @@ is updated then the close event is not sent unless the updated notification
also requests a close notification.
Note that on some platforms, such as macOS, the OS does not inform applications
when notifications are closed, on such platforms, terminals may reply with::
when notifications are closed, on such platforms, terminals reply with::
<OSC> 99 ; i=mynotification : p=close ; untracked <terminator>
This means that the terminal has no way of knowing when the notification is
closed. |kitty|, on macOS, manually tracks notifications by polling the OS
for a short period to see if they are closed, after which it gives up
and replies with an ``untracked`` response.
closed. Instead, applications can poll the terminal to determine which
notifications are still alive (not closed), with::
<OSC> 99 ; i=myid : p=alive ; <terminator>
The terminal will reply with::
<OSC> 99 ; i=myid : p=alive ; id1,id2,id3 <terminator>
Here, ``myid`` is present for multiplxer support. The reponse from the terminal
contains a comma separated list of ids that are still alive.
Updating or closing an existing notification
----------------------------------------------
@ -244,7 +252,7 @@ Key Value Default Description
``p`` One of ``title``, ``title`` Whether the payload is the notification title or body or query. If a
``body``, notification has no title, the body will be used as title. Terminal
``close``, emulators should ignore payloads of unknown type to allow for future
``?`` expansion of this protocol.
``?``, ``alive`` expansion of this protocol.
``o`` One of ``always``, ``always`` When to honor the notification request. ``unfocused`` means when the window

View file

@ -1145,7 +1145,7 @@ static bool has_cocoa_pending_actions = false;
typedef struct cocoa_list { char **items; size_t count, capacity; } cocoa_list;
typedef struct {
char* wd;
cocoa_list open_urls, closed_notifications, untracked_notifications;
cocoa_list open_urls, untracked_notifications;
} CocoaPendingActionsData;
static CocoaPendingActionsData cocoa_pending_actions_data = {0};
@ -1165,7 +1165,6 @@ static void
cocoa_free_actions_data(void) {
if (cocoa_pending_actions_data.wd) { free(cocoa_pending_actions_data.wd); cocoa_pending_actions_data.wd = NULL; }
cocoa_free_pending_list(&cocoa_pending_actions_data.open_urls);
cocoa_free_pending_list(&cocoa_pending_actions_data.closed_notifications);
cocoa_free_pending_list(&cocoa_pending_actions_data.untracked_notifications);
}
@ -1175,8 +1174,6 @@ set_cocoa_pending_action(CocoaPendingAction action, const char *data) {
switch(action) {
case LAUNCH_URLS:
cocoa_append_to_pending_list(&cocoa_pending_actions_data.open_urls, data); break;
case COCOA_NOTIFICATION_CLOSED:
cocoa_append_to_pending_list(&cocoa_pending_actions_data.closed_notifications, data); break;
case COCOA_NOTIFICATION_UNTRACKED:
cocoa_append_to_pending_list(&cocoa_pending_actions_data.untracked_notifications, data); break;
default:
@ -1230,18 +1227,9 @@ process_cocoa_pending_actions(void) {
}
cocoa_pending_actions_data.open_urls.count = 0;
for (unsigned cpa = 0; cpa < cocoa_pending_actions_data.closed_notifications.count; cpa++) {
if (cocoa_pending_actions_data.closed_notifications.items[cpa]) {
cocoa_report_closed_notification(cocoa_pending_actions_data.closed_notifications.items[cpa], false);
free(cocoa_pending_actions_data.closed_notifications.items[cpa]);
cocoa_pending_actions_data.closed_notifications.items[cpa] = NULL;
}
}
cocoa_pending_actions_data.closed_notifications.count = 0;
for (unsigned cpa = 0; cpa < cocoa_pending_actions_data.untracked_notifications.count; cpa++) {
if (cocoa_pending_actions_data.untracked_notifications.items[cpa]) {
cocoa_report_closed_notification(cocoa_pending_actions_data.untracked_notifications.items[cpa], true);
cocoa_report_live_notifications(cocoa_pending_actions_data.untracked_notifications.items[cpa]);
free(cocoa_pending_actions_data.untracked_notifications.items[cpa]);
cocoa_pending_actions_data.untracked_notifications.items[cpa] = NULL;
}

View file

@ -34,7 +34,6 @@ typedef enum {
MINIMIZE,
QUIT,
USER_MENU_ACTION,
COCOA_NOTIFICATION_CLOSED,
COCOA_NOTIFICATION_UNTRACKED,
NUM_COCOA_PENDING_ACTIONS
@ -60,4 +59,4 @@ bool cocoa_render_line_of_text(const char *text, const color_type fg, const colo
extern uint8_t* render_single_ascii_char_as_mask(const char ch, size_t *result_width, size_t *result_height);
void get_cocoa_key_equivalent(uint32_t, int, char *key, size_t key_sz, int*);
void set_cocoa_pending_action(CocoaPendingAction action, const char*);
void cocoa_report_closed_notification(const char* ident, bool untracked);
void cocoa_report_live_notifications(const char* ident);

View file

@ -435,7 +435,7 @@ cocoa_send_notification(PyObject *self UNUSED, PyObject *args) {
do_notification_callback([[[response notification] request] identifier], "activated");
} else if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) {
// this never actually happens on macOS. Bloody Crapple.
do_notification_callback([[[response notification] request] identifier], "closed");
// do_notification_callback([[[response notification] request] identifier], "closed");
}
completionHandler();
}
@ -457,23 +457,6 @@ get_notification_center_safely(void) {
return center;
}
static bool
remove_delivered_notification(const char *identifier) {
UNUserNotificationCenter *center = get_notification_center_safely();
if (!center) return false;
[center removeDeliveredNotificationsWithIdentifiers:@[ @(identifier) ]];
return true;
}
static NSLock *notifications_polling_lock = NULL;
static bool polling_notifications = false;
typedef struct tn { char *ident; bool closed; monotonic_t creation_time; } tn;
static struct { tn *items; size_t count, capacity; monotonic_t creation_time; } tracked_notifications;
static void dispatch_closed_notifications(void);
#define CLOSE_POLL_TIME 60.
#define CLOSE_POLL_INTERVAL 750
static bool
ident_in_list_of_notifications(NSString *ident, NSArray<UNNotification*> *list) {
for (UNNotification *n in list) {
@ -482,82 +465,40 @@ ident_in_list_of_notifications(NSString *ident, NSArray<UNNotification*> *list)
return false;
}
static void
poll_for_closed_notifications(void) {
UNUserNotificationCenter *center = get_notification_center_safely();
if (!center) return;
[center getDeliveredNotificationsWithCompletionHandler:^(NSArray<UNNotification *> * notifications) {
// NSLog(@"num of delivered but not closed nots: %lu", (unsigned long)[notifications count]);
[notifications_polling_lock lock];
for (size_t i = 0; i < tracked_notifications.count; i++) {
if (!ident_in_list_of_notifications(@(tracked_notifications.items[i].ident), notifications)) tracked_notifications.items[i].closed = true;
}
[notifications_polling_lock unlock];
dispatch_async(dispatch_get_main_queue(), ^{
dispatch_closed_notifications();
});
}];
}
void
cocoa_report_closed_notification(const char* ident, bool untracked) {
do_notification_callback(@(ident), untracked ? "untracked" : "closed");
cocoa_report_live_notifications(const char* ident) {
do_notification_callback(@(ident), "live");
}
static void
dispatch_closed_notifications(void) {
bool poll = false;
[notifications_polling_lock lock];
monotonic_t now = monotonic();
for (size_t i = tracked_notifications.count; i-- > 0; ) {
if (tracked_notifications.items[i].closed) {
set_cocoa_pending_action(COCOA_NOTIFICATION_CLOSED, tracked_notifications.items[i].ident);
free(tracked_notifications.items[i].ident);
remove_i_from_array(tracked_notifications.items, i, tracked_notifications.count);
} else {
if (now - tracked_notifications.items[i].creation_time < s_double_to_monotonic_t(CLOSE_POLL_TIME)) {
poll = true;
} else {
set_cocoa_pending_action(COCOA_NOTIFICATION_UNTRACKED, tracked_notifications.items[i].ident);
free(tracked_notifications.items[i].ident);
remove_i_from_array(tracked_notifications.items, i, tracked_notifications.count);
}
}
}
polling_notifications = poll;
[notifications_polling_lock unlock];
if (poll) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, CLOSE_POLL_INTERVAL * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{
poll_for_closed_notifications();
});
}
}
static void
track_notification(char *ident) {
static bool
remove_delivered_notification(const char *identifier) {
UNUserNotificationCenter *center = get_notification_center_safely();
if (center) {
[notifications_polling_lock lock];
bool has_existing = false;
for (size_t i = 0; i < tracked_notifications.count; i++) {
if (strcmp(tracked_notifications.items[i].ident, ident) == 0) {
tracked_notifications.items[i].creation_time = monotonic();
has_existing = true;
break;
}
if (!center) return false;
char *ident = strdup(identifier);
[center getDeliveredNotificationsWithCompletionHandler:^(NSArray<UNNotification *> * notifications) {
if (ident_in_list_of_notifications(@(ident), notifications)) {
[center removeDeliveredNotificationsWithIdentifiers:@[ @(ident) ]];
}
if (!has_existing) {
ensure_space_for(&tracked_notifications, items, tn, tracked_notifications.count + 1, capacity, 8, false);
tracked_notifications.items[tracked_notifications.count++] = (tn){.ident=ident, .creation_time=monotonic()};
}
bool needs_poll = !polling_notifications;
[notifications_polling_lock unlock];
if (needs_poll) poll_for_closed_notifications();
}
free(ident);
}];
return true;
}
static bool
live_delivered_notifications(void) {
UNUserNotificationCenter *center = get_notification_center_safely();
if (!center) return false;
[center getDeliveredNotificationsWithCompletionHandler:^(NSArray<UNNotification *> * notifications) {
NSMutableString *buffer = [[NSMutableString stringWithCapacity:1024] autorelease];
for (UNNotification *n in notifications) [buffer appendFormat:@"%@,", [[n request] identifier]];
const char *val = [buffer UTF8String];
set_cocoa_pending_action(COCOA_NOTIFICATION_UNTRACKED, val ? val : "");
}];
return true;
}
static void
schedule_notification(const char *identifier, const char *title, const char *body, bool track_closing, int urgency) {
schedule_notification(const char *identifier, const char *title, const char *body, int urgency) {
UNUserNotificationCenter *center = get_notification_center_safely();
if (!center) return;
// Configure the notification's payload.
@ -591,9 +532,8 @@ schedule_notification(const char *identifier, const char *title, const char *bod
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" : "closed");
if (ok && track_closing) track_notification(duped_ident);
else free(duped_ident);
do_notification_callback(@(duped_ident), ok ? "created" : "creation_failed");
free(duped_ident);
});
}];
[content release];
@ -602,7 +542,7 @@ schedule_notification(const char *identifier, const char *title, const char *bod
typedef struct {
char *identifier, *title, *body;
int urgency; bool track_closing;
int urgency;
} QueuedNotification;
typedef struct {
@ -612,13 +552,13 @@ typedef struct {
static NotificationQueue notification_queue = {0};
static void
queue_notification(const char *identifier, const char *title, const char* body, bool track_closing, int urgency) {
queue_notification(const char *identifier, const char *title, const char* body, int urgency) {
ensure_space_for((&notification_queue), notifications, QueuedNotification, notification_queue.count + 16, capacity, 16, true);
QueuedNotification *n = notification_queue.notifications + notification_queue.count++;
n->identifier = identifier ? strdup(identifier) : NULL;
n->title = title ? strdup(title) : NULL;
n->body = body ? strdup(body) : NULL;
n->urgency = urgency; n->track_closing = track_closing;
n->urgency = urgency;
}
static void
@ -626,12 +566,12 @@ 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->identifier, n->title, n->body, n->track_closing, n->urgency);
schedule_notification(n->identifier, n->title, n->body, n->urgency);
}
}
while(notification_queue.count) {
QueuedNotification *n = notification_queue.notifications + --notification_queue.count;
if (!granted) do_notification_callback(@(n->identifier), "closed");
if (!granted) do_notification_callback(@(n->identifier), "creation_failed");
free(n->identifier); free(n->title); free(n->body);
memset(n, 0, sizeof(QueuedNotification));
}
@ -644,16 +584,23 @@ cocoa_remove_delivered_notification(PyObject *self UNUSED, PyObject *x) {
Py_RETURN_FALSE;
}
static PyObject*
cocoa_live_delivered_notifications(PyObject *self UNUSED, PyObject *x UNUSED) {
if (live_delivered_notifications()) { Py_RETURN_TRUE; }
Py_RETURN_FALSE;
}
static PyObject*
cocoa_send_notification(PyObject *self UNUSED, PyObject *args) {
char *identifier = NULL, *title = NULL, *body = NULL; int urgency = 1;
int track_closing;
if (!PyArg_ParseTuple(args, "sssp|i", &identifier, &title, &body, &track_closing, &urgency)) return NULL;
if (!PyArg_ParseTuple(args, "sss|i", &identifier, &title, &body, &urgency)) return NULL;
UNUserNotificationCenter *center = get_notification_center_safely();
if (!center) Py_RETURN_NONE;
if (!center.delegate) center.delegate = [[NotificationDelegate alloc] init];
queue_notification(identifier, title, body, track_closing, urgency);
queue_notification(identifier, title, body, urgency);
// 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.
@ -1123,8 +1070,6 @@ cleanup(void) {
dockMenu = nil;
if (beep_sound) [beep_sound release];
beep_sound = nil;
if (notifications_polling_lock) [notifications_polling_lock release];
notifications_polling_lock = nil;
#ifndef KITTY_USE_DEPRECATED_MACOS_NOTIFICATION_API
drain_pending_notifications(NO);
@ -1164,6 +1109,7 @@ static PyMethodDef module_methods[] = {
{"cocoa_set_global_shortcut", (PyCFunction)cocoa_set_global_shortcut, METH_VARARGS, ""},
{"cocoa_send_notification", (PyCFunction)cocoa_send_notification, METH_VARARGS, ""},
{"cocoa_remove_delivered_notification", (PyCFunction)cocoa_remove_delivered_notification, METH_O, ""},
{"cocoa_live_delivered_notifications", (PyCFunction)cocoa_live_delivered_notifications, METH_NOARGS, ""},
{"cocoa_set_notification_activated_callback", (PyCFunction)set_notification_activated_callback, METH_O, ""},
{"cocoa_set_url_handler", (PyCFunction)cocoa_set_url_handler, METH_VARARGS, ""},
{"cocoa_set_app_icon", (PyCFunction)cocoa_set_app_icon, METH_VARARGS, ""},
@ -1175,7 +1121,6 @@ bool
init_cocoa(PyObject *module) {
cocoa_clear_global_shortcuts();
if (PyModule_AddFunctions(module, module_methods) != 0) return false;
notifications_polling_lock = [NSLock new];
register_at_exit_cleanup_func(COCOA_CLEANUP_FUNC, cleanup);
return true;
}

View file

@ -568,12 +568,12 @@ def cocoa_send_notification(
identifier: str,
title: str,
body: str,
track_closing: bool,
urgency: int = 1,
) -> None:
pass
def cocoa_remove_delivered_notification(identifier: str) -> bool: ...
def cocoa_live_delivered_notifications() -> bool: ...
def create_os_window(
get_window_size: Callable[[int, int, int, int, float, float], Tuple[int,

View file

@ -7,7 +7,7 @@ from collections import OrderedDict
from contextlib import suppress
from enum import Enum
from itertools import count
from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Set, Tuple, Union
from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Sequence, Set, Tuple, Union
from weakref import ReferenceType, ref
from .constants import cache_dir, config_dir, is_macos, logo_png_file
@ -111,6 +111,7 @@ class PayloadType(Enum):
query = '?'
close = 'close'
icon = 'icon'
alive = 'alive'
@property
def is_text(self) -> bool:
@ -203,7 +204,7 @@ class NotificationCommand:
# event callbacks
on_activation: Optional[Callable[['NotificationCommand'], None]] = None
on_close: Optional[Callable[['NotificationCommand', bool], None]] = None
on_close: Optional[Callable[['NotificationCommand'], None]] = None
on_update: Optional[Callable[['NotificationCommand', 'NotificationCommand'], None]] = None
# metadata
@ -410,6 +411,9 @@ class DesktopIntegration:
def initialize(self) -> None:
pass
def query_live_notifications(self, channel_id: int, identifier: str) -> None:
raise NotImplementedError('Implement me in subclass')
def close_notification(self, desktop_notification_id: int) -> bool:
raise NotImplementedError('Implement me in subclass')
@ -432,11 +436,21 @@ class DesktopIntegration:
class MacOSIntegration(DesktopIntegration):
supports_close_events: bool = False
def initialize(self) -> None:
from .fast_data_types import cocoa_set_notification_activated_callback
self.id_counter = count(start=1)
self.live_notification_queries: List[Tuple[int, str]] = []
cocoa_set_notification_activated_callback(self.notification_activated)
def query_live_notifications(self, channel_id: int, identifier: str) -> None:
from .fast_data_types import cocoa_live_delivered_notifications
if not cocoa_live_delivered_notifications():
self.notification_manager.send_live_response(channel_id, identifier, ())
else:
self.live_notification_queries.append((channel_id, identifier))
def close_notification(self, desktop_notification_id: int) -> bool:
from .fast_data_types import cocoa_remove_delivered_notification
close_succeeded = cocoa_remove_delivered_notification(str(desktop_notification_id))
@ -455,12 +469,19 @@ class MacOSIntegration(DesktopIntegration):
# for %% escaping.
body = (nc.body or ' ')
assert nc.urgency is not None
cocoa_send_notification(str(desktop_notification_id), nc.title, body, bool(nc.close_response_requested), nc.urgency.value)
cocoa_send_notification(str(desktop_notification_id), nc.title, body, nc.urgency.value)
return desktop_notification_id
def notification_activated(self, event: str, ident: str) -> None:
if debug_desktop_integration:
log_error(f'Notification {ident} {event=}')
if event == 'live':
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
for channel_id, req_id in queries:
self.notification_manager.send_live_response(channel_id, req_id, live_ids)
return
try:
desktop_notification_id = int(ident)
except Exception:
@ -468,12 +489,12 @@ class MacOSIntegration(DesktopIntegration):
return
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":
self.notification_manager.notification_activated(desktop_notification_id)
elif event == "closed":
elif event == "creation_failed":
self.notification_manager.notification_closed(desktop_notification_id)
elif event == "untracked":
self.notification_manager.notification_closed(desktop_notification_id, True)
class FreeDesktopIntegration(DesktopIntegration):
@ -486,6 +507,9 @@ class FreeDesktopIntegration(DesktopIntegration):
self.dbus_to_desktop: 'OrderedDict[int, int]' = OrderedDict()
self.desktop_to_dbus: Dict[int, int] = {}
def query_live_notifications(self, channel_id: int, identifier: str) -> None:
self.notification_manager.send_live_response(channel_id, identifier, tuple(self.desktop_to_dbus))
def close_notification(self, desktop_notification_id: int) -> bool:
from .fast_data_types import dbus_close_notification
close_succeeded = False
@ -672,14 +696,14 @@ class NotificationManager:
except Exception as e:
self.log('Notification on_update handler failed with error:', e)
def notification_closed(self, desktop_notification_id: int, untracked: bool = False) -> None:
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:
self.send_closed_response(n.channel_id, n.identifier, untracked)
self.send_closed_response(n.channel_id, n.identifier)
if n.on_close is not None:
try:
n.on_close(n, untracked)
n.on_close(n)
except Exception as e:
self.log('Notification on_close handler failed with error:', e)
@ -740,6 +764,8 @@ class NotificationManager:
self.register_in_progress_notification(cmd, desktop_notification_id)
if existing_cmd:
self.notification_replaced(existing_cmd, cmd)
if not self.desktop_integration.supports_close_events and cmd.close_response_requested:
self.send_closed_response(channel_id, cmd.identifier, untracked=True)
return desktop_notification_id
def register_in_progress_notification(self, cmd: NotificationCommand, desktop_notification_id: int) -> None:
@ -764,6 +790,10 @@ class NotificationManager:
if payload_type is PayloadType.query:
self.channel.send(channel_id, self.desktop_integration.query_response(cmd.identifier))
return None
if payload_type is PayloadType.alive:
if cmd.identifier:
self.desktop_integration.query_live_notifications(channel_id, cmd.identifier)
return None
if payload_type is PayloadType.close:
if cmd.identifier:
to_close = self.in_progress_notification_commands_by_client_id.get(cmd.identifier)
@ -783,7 +813,19 @@ class NotificationManager:
def send_closed_response(self, channel_id: int, client_id: str, untracked: bool = False) -> None:
payload = 'untracked' if untracked else ''
self.channel.send(channel_id, f'99;i={client_id}:p=close;{payload}')
self.channel.send(channel_id, f'99;i={client_id}:p={PayloadType.close.value};{payload}')
def send_live_response(self, channel_id: int, client_id: str, live_desktop_ids: Sequence[int]) -> None:
ids = []
for desktop_notification_id in live_desktop_ids:
if n := self.in_progress_notification_commands.get(desktop_notification_id):
if n.identifier and n.channel_id == channel_id:
ids.append(n.identifier)
self.channel.send(channel_id, f'99;i={client_id}:p={PayloadType.alive.value};{",".join(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):
self.purge_notification(self.in_progress_notification_commands[d])
def purge_notification(self, cmd: NotificationCommand) -> None:
self.in_progress_notification_commands_by_client_id.pop(cmd.identifier, None)

View file

@ -219,7 +219,7 @@ def do_test(self: 'TestNotifications', tdir: str) -> None:
# Test querying
h('i=xyz:p=?')
self.assertFalse(di.notifications)
qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close,icon:c=1'
qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close,icon,alive:c=1'
self.ae(ch.responses, [f'99;i=xyz:p=?;{qr}'])
reset()
h('p=?')