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 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
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 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
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 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
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 @@ - (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" : "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 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
typedef struct {
char *identifier, *title, *body;
int urgency; bool track_closing;
int urgency;
} QueuedNotification;
typedef struct {
@ -612,13 +552,13 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
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 @@ - (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->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 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
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 @@ - (BOOL)openFileURLs:(NSPasteboard*)pasteboard
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 @@ - (BOOL)openFileURLs:(NSPasteboard*)pasteboard
{"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)openFileURLs:(NSPasteboard*)pasteboard
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 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 @@ def __init__(self, notification_manager: 'NotificationManager'):
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 @@ def query_response(self, identifier: str) -> str:
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 @@ def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Opti
# 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 @@ def notification_activated(self, event: str, ident: str) -> None:
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 @@ def initialize(self) -> None:
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 @@ def notification_replaced(self, old_cmd: NotificationCommand, new_cmd: Notificat
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 @@ def notify_with_command(self, cmd: NotificationCommand, channel_id: int) -> Opti
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 @@ def parse_notification_cmd(
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 @@ def parse_notification_cmd(
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 assert_events(focus=True, close=0, report='', close_response=''):
# 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=?')