Add support for auto-expiring notifications

This commit is contained in:
Kovid Goyal
2024-07-28 20:14:39 +05:30
parent d1a8772ac8
commit eca487d15f
3 changed files with 55 additions and 10 deletions

View File

@@ -171,6 +171,20 @@ This will close a previous notification with the specified id. If no such
notification exists (perhaps because it was already closed or it was activated) notification exists (perhaps because it was already closed or it was activated)
then the request is ignored. then the request is ignored.
Automatically expiring notifications
-------------------------------------
A notification can be marked as expiring (being closed) automatically after
a specified number of milliseconds using the ``w`` key. The default if
unspecified is ``-1`` which means to use whatever expiry policy the OS has for
notifications. A value of ``0`` means the notification should never expire.
Values greater than zero specify the number of milliseconds after which the
notification should be auto-closed. Note that the value of ``0``
is best effort, some platforms honor it and some do not. Positive values
are robust, since they can be implemented by the terminal emulator itself,
by manually closing the notification after the expiry time.
.. _notifications_query: .. _notifications_query:
Querying for support Querying for support
@@ -215,6 +229,8 @@ Key Value
``c`` ``c=1`` if the terminal supports close events, otherwise the ``c`` ``c`` ``c=1`` if the terminal supports close events, otherwise the ``c``
must be omitted. must be omitted.
``w`` ``w=1`` if the terminal supports auto expiring of notifications.
======= ================================================================================ ======= ================================================================================
In the future, if this protocol expands, more keys might be added. Clients must In the future, if this protocol expands, more keys might be added. Clients must
@@ -274,6 +290,8 @@ Key Value Default Description
``t`` :rfc:`base64 <4648>` ``unset`` The type of the notification. Can be used to filter out notifications. ``t`` :rfc:`base64 <4648>` ``unset`` The type of the notification. Can be used to filter out notifications.
encoded UTF-8 encoded UTF-8
notification type notification type
``w`` ``>=-1`` ``-1`` The number of milliseconds to auto-close the notification after.
======= ==================== ========== ================= ======= ==================== ========== =================

View File

@@ -6,12 +6,13 @@ import re
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from enum import Enum from enum import Enum
from functools import partial
from itertools import count from itertools import count
from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Sequence, Set, Tuple, Union from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Sequence, Set, Tuple, Union
from weakref import ReferenceType, ref from weakref import ReferenceType, ref
from .constants import cache_dir, config_dir, is_macos, logo_png_file from .constants import cache_dir, config_dir, is_macos, logo_png_file
from .fast_data_types import ESC_OSC, StreamingBase64Decoder, base64_decode, current_focused_os_window_id, get_boss, get_options from .fast_data_types import ESC_OSC, StreamingBase64Decoder, add_timer, base64_decode, current_focused_os_window_id, get_boss, get_options
from .types import run_once from .types import run_once
from .typing import WindowType from .typing import WindowType
from .utils import get_custom_window_icon, log_error, sanitize_control_codes from .utils import get_custom_window_icon, log_error, sanitize_control_codes
@@ -201,6 +202,7 @@ class NotificationCommand:
icon_name: str = '' icon_name: str = ''
application_name: str = '' application_name: str = ''
notification_type: str = '' notification_type: str = ''
timeout: int = -2
# event callbacks # event callbacks
on_activation: Optional[Callable[['NotificationCommand'], None]] = None on_activation: Optional[Callable[['NotificationCommand'], None]] = None
@@ -297,6 +299,11 @@ class NotificationCommand:
self.notification_type = base64_decode(v).decode('utf-8', 'replace') self.notification_type = base64_decode(v).decode('utf-8', 'replace')
except Exception: except Exception:
self.log('Ignoring invalid notification type in notification: {v!r}') self.log('Ignoring invalid notification type in notification: {v!r}')
elif k == 'w':
try:
self.timeout = max(-1, int(v))
except Exception:
self.log('Ignoring invalid timeout in notification: {v!r}')
if not prev.done and prev.identifier == self.identifier: if not prev.done and prev.identifier == self.identifier:
self.merge_metadata(prev) self.merge_metadata(prev)
return payload_type, payload_is_encoded return payload_type, payload_is_encoded
@@ -319,6 +326,8 @@ class NotificationCommand:
self.application_name = prev.application_name self.application_name = prev.application_name
if not self.notification_type: if not self.notification_type:
self.notification_type = prev.notification_type self.notification_type = prev.notification_type
if self.timeout < -1:
self.timeout = prev.timeout
self.icon_path = prev.icon_path self.icon_path = prev.icon_path
def create_payload_buffer(self, payload_type: PayloadType) -> EncodedDataStore: def create_payload_buffer(self, payload_type: PayloadType) -> EncodedDataStore:
@@ -380,6 +389,7 @@ class NotificationCommand:
self.body = '' self.body = ''
self.urgency = Urgency.Normal if self.urgency is None else self.urgency self.urgency = Urgency.Normal if self.urgency is None else self.urgency
self.close_response_requested = bool(self.close_response_requested) self.close_response_requested = bool(self.close_response_requested)
self.timeout = max(-1, self.timeout)
def matches_rule_item(self, location:str, query:str) -> bool: def matches_rule_item(self, location:str, query:str) -> bool:
import re import re
@@ -431,7 +441,7 @@ class DesktopIntegration:
i = f'i={identifier or "0"}:' i = f'i={identifier or "0"}:'
p = ','.join(x.value for x in PayloadType if x.value) p = ','.join(x.value for x in PayloadType if x.value)
c = ':c=1' if self.supports_close_events else '' c = ':c=1' if self.supports_close_events else ''
return f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}{c}' return f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}{c}:w=1'
class MacOSIntegration(DesktopIntegration): class MacOSIntegration(DesktopIntegration):
@@ -473,15 +483,17 @@ class MacOSIntegration(DesktopIntegration):
return desktop_notification_id return desktop_notification_id
def notification_activated(self, event: str, ident: str) -> None: def notification_activated(self, event: str, ident: str) -> None:
if debug_desktop_integration:
log_error(f'Notification {ident} {event=}')
if event == 'live': 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) live_ids = tuple(int(x) for x in ident.split(',') if x)
self.notification_manager.purge_dead_notifications(live_ids) self.notification_manager.purge_dead_notifications(live_ids)
self.live_notification_queries, queries = [], self.live_notification_queries self.live_notification_queries, queries = [], self.live_notification_queries
for channel_id, req_id in queries: for channel_id, req_id in queries:
self.notification_manager.send_live_response(channel_id, req_id, live_ids) self.notification_manager.send_live_response(channel_id, req_id, live_ids)
return return
if debug_desktop_integration:
log_error(f'Notification {ident} {event=}')
try: try:
desktop_notification_id = int(ident) desktop_notification_id = int(ident)
except Exception: except Exception:
@@ -564,8 +576,8 @@ class FreeDesktopIntegration(DesktopIntegration):
if existing_desktop_notification_id: if existing_desktop_notification_id:
replaces_dbus_id = self.get_dbus_notification_id(existing_desktop_notification_id, 'notify') or 0 replaces_dbus_id = self.get_dbus_notification_id(existing_desktop_notification_id, 'notify') or 0
desktop_notification_id = dbus_send_notification( desktop_notification_id = dbus_send_notification(
app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, timeout=-1, urgency=nc.urgency.value, app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, timeout=nc.timeout,
replaces=replaces_dbus_id) urgency=nc.urgency.value, replaces=replaces_dbus_id)
if debug_desktop_integration: if debug_desktop_integration:
log_error(f'Requested creation of notification with {desktop_notification_id=}') log_error(f'Requested creation of notification with {desktop_notification_id=}')
if existing_desktop_notification_id and replaces_dbus_id: if existing_desktop_notification_id and replaces_dbus_id:
@@ -667,6 +679,8 @@ class NotificationManager:
def notification_created(self, desktop_notification_id: int) -> None: def notification_created(self, desktop_notification_id: int) -> None:
if n := self.in_progress_notification_commands.get(desktop_notification_id): if n := self.in_progress_notification_commands.get(desktop_notification_id):
n.created_by_desktop = True n.created_by_desktop = True
if n.timeout > 0:
add_timer(partial(self.expire_notification, desktop_notification_id, id(n)), n.timeout / 1000, False)
def notification_activation_token_received(self, desktop_notification_id: int, token: str) -> None: def notification_activation_token_received(self, desktop_notification_id: int, token: str) -> None:
if n := self.in_progress_notification_commands.get(desktop_notification_id): if n := self.in_progress_notification_commands.get(desktop_notification_id):
@@ -768,6 +782,11 @@ class NotificationManager:
self.send_closed_response(channel_id, cmd.identifier, untracked=True) self.send_closed_response(channel_id, cmd.identifier, untracked=True)
return desktop_notification_id return desktop_notification_id
def expire_notification(self, desktop_notification_id: int, command_id: int, timer_id: int) -> None:
if n := self.in_progress_notification_commands.get(desktop_notification_id):
if id(n) == command_id:
self.desktop_integration.close_notification(desktop_notification_id)
def register_in_progress_notification(self, cmd: NotificationCommand, desktop_notification_id: int) -> None: def register_in_progress_notification(self, cmd: NotificationCommand, desktop_notification_id: int) -> None:
cmd.desktop_notification_id = desktop_notification_id cmd.desktop_notification_id = desktop_notification_id
self.in_progress_notification_commands[desktop_notification_id] = cmd self.in_progress_notification_commands[desktop_notification_id] = cmd

View File

@@ -12,10 +12,13 @@ from kitty.notifications import Channel, DesktopIntegration, IconDataCache, Noti
from . import BaseTest from . import BaseTest
def n(title='title', body='', urgency=Urgency.Normal, desktop_notification_id=1, icon_name='', icon_path='', application_name='', notification_type=''): def n(
title='title', body='', urgency=Urgency.Normal, desktop_notification_id=1, icon_name='', icon_path='',
application_name='', notification_type='', timeout=-1,
):
return { return {
'title': title, 'body': body, 'urgency': urgency, 'id': desktop_notification_id, 'icon_name': icon_name, 'icon_path': icon_path, 'title': title, 'body': body, 'urgency': urgency, 'id': desktop_notification_id, 'icon_name': icon_name, 'icon_path': icon_path,
'application_name': application_name, 'notification_type': notification_type, 'application_name': application_name, 'notification_type': notification_type, 'timeout': timeout
} }
@@ -50,7 +53,7 @@ class DesktopIntegration(DesktopIntegration):
self.counter += 1 self.counter += 1
did = self.counter did = self.counter
title, body, urgency = cmd.title, cmd.body, (Urgency.Normal if cmd.urgency is None else cmd.urgency) title, body, urgency = cmd.title, cmd.body, (Urgency.Normal if cmd.urgency is None else cmd.urgency)
ans = n(title, body, urgency, did, cmd.icon_name, os.path.basename(cmd.icon_path), cmd.application_name, cmd.notification_type) ans = n(title, body, urgency, did, cmd.icon_name, os.path.basename(cmd.icon_path), cmd.application_name, cmd.notification_type, timeout=cmd.timeout)
self.notifications.append(ans) self.notifications.append(ans)
return self.counter return self.counter
@@ -235,7 +238,7 @@ def do_test(self: 'TestNotifications', tdir: str) -> None:
# Test querying # Test querying
h('i=xyz:p=?') h('i=xyz:p=?')
self.assertFalse(di.notifications) self.assertFalse(di.notifications)
qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close,icon,alive:c=1' qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close,icon,alive:c=1:w=1'
self.ae(ch.responses, [f'99;i=xyz:p=?;{qr}']) self.ae(ch.responses, [f'99;i=xyz:p=?;{qr}'])
reset() reset()
h('p=?') h('p=?')
@@ -266,6 +269,11 @@ def do_test(self: 'TestNotifications', tdir: str) -> None:
self.ae(di.notifications, [n(application_name='app', notification_type='test')]) self.ae(di.notifications, [n(application_name='app', notification_type='test')])
reset() reset()
# Test timeout
h('w=3;title')
self.ae(di.notifications, [n(timeout=3)])
reset()
# Test Disk Cache # Test Disk Cache
dc = IconDataCache(base_cache_dir=tdir, max_cache_size=4) dc = IconDataCache(base_cache_dir=tdir, max_cache_size=4)
cache_dir = dc._ensure_state() cache_dir = dc._ensure_state()