Start work on refactoring notifications handling
Makes the code cleaner and easily mockable for testing. Also, add code to handle closing notifications on Linux.
This commit is contained in:
parent
31cee3e966
commit
d68e49fe64
14 changed files with 798 additions and 497 deletions
|
|
@ -364,7 +364,7 @@ def generate_wrappers(glfw_header: str) -> None:
|
|||
typedef void (* GLFWcocoarenderframefun)(GLFWwindow*);
|
||||
typedef void (*GLFWwaylandframecallbackfunc)(unsigned long long id);
|
||||
typedef void (*GLFWDBusnotificationcreatedfun)(unsigned long long, uint32_t, void*);
|
||||
typedef void (*GLFWDBusnotificationactivatedfun)(uint32_t, const char*);
|
||||
typedef void (*GLFWDBusnotificationactivatedfun)(uint32_t, int, const char*);
|
||||
{}
|
||||
|
||||
const char* load_glfw(const char* path);
|
||||
|
|
|
|||
38
glfw/linux_notify.c
vendored
38
glfw/linux_notify.c
vendored
|
|
@ -50,26 +50,58 @@ message_handler(DBusConnection *conn UNUSED, DBusMessage *msg, void *user_data U
|
|||
/* printf("session_bus message_handler invoked interface: %s member: %s\n", dbus_message_get_interface(msg), dbus_message_get_member(msg)); */
|
||||
if (dbus_message_is_signal(msg, NOTIFICATIONS_IFACE, "ActionInvoked")) {
|
||||
uint32_t id;
|
||||
const char *action;
|
||||
const char *action = NULL;
|
||||
if (glfw_dbus_get_args(msg, "Failed to get args from ActionInvoked notification signal",
|
||||
DBUS_TYPE_UINT32, &id, DBUS_TYPE_STRING, &action, DBUS_TYPE_INVALID)) {
|
||||
if (activated_handler) {
|
||||
activated_handler(id, action);
|
||||
activated_handler(id, 2, action);
|
||||
return DBUS_HANDLER_RESULT_HANDLED;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (dbus_message_is_signal(msg, NOTIFICATIONS_IFACE, "ActivationToken")) {
|
||||
uint32_t id;
|
||||
const char *token = NULL;
|
||||
if (glfw_dbus_get_args(msg, "Failed to get args from ActivationToken notification signal",
|
||||
DBUS_TYPE_UINT32, &id, DBUS_TYPE_STRING, &token, DBUS_TYPE_INVALID)) {
|
||||
if (activated_handler) {
|
||||
activated_handler(id, 1, token);
|
||||
return DBUS_HANDLER_RESULT_HANDLED;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (dbus_message_is_signal(msg, NOTIFICATIONS_IFACE, "NotificationClosed")) {
|
||||
uint32_t id;
|
||||
if (glfw_dbus_get_args(msg, "Failed to get args from NotificationClosed notification signal",
|
||||
DBUS_TYPE_UINT32, &id, DBUS_TYPE_INVALID)) {
|
||||
if (activated_handler) {
|
||||
activated_handler(id, 0, "");
|
||||
return DBUS_HANDLER_RESULT_HANDLED;
|
||||
}
|
||||
}
|
||||
}
|
||||
return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
|
||||
}
|
||||
|
||||
static bool
|
||||
cancel_user_notification(DBusConnection *session_bus, uint32_t *id) {
|
||||
return glfw_dbus_call_method_no_reply(session_bus, NOTIFICATIONS_SERVICE, NOTIFICATIONS_PATH, NOTIFICATIONS_IFACE, "CloseNotification", DBUS_TYPE_UINT32, id, DBUS_TYPE_INVALID);
|
||||
}
|
||||
|
||||
notification_id_type
|
||||
glfw_dbus_send_user_notification(const char *app_name, const char* icon, const char *summary, const char *body, const char* action_name, int32_t timeout, int urgency, GLFWDBusnotificationcreatedfun callback, void *user_data) {
|
||||
DBusConnection *session_bus = glfw_dbus_session_bus();
|
||||
static DBusConnection *added_signal_match = NULL;
|
||||
if (!session_bus) return 0;
|
||||
if (timeout == -9999 && urgency == -9999) return cancel_user_notification(session_bus, user_data) ? 1 : 0;
|
||||
static DBusConnection *added_signal_match = NULL;
|
||||
if (added_signal_match != session_bus) {
|
||||
dbus_bus_add_match(session_bus, "type='signal',interface='" NOTIFICATIONS_IFACE "',member='ActionInvoked'", NULL);
|
||||
dbus_bus_add_match(session_bus, "type='signal',interface='" NOTIFICATIONS_IFACE "',member='NotificationClosed'", NULL);
|
||||
dbus_bus_add_match(session_bus, "type='signal',interface='" NOTIFICATIONS_IFACE "',member='ActivationToken'", NULL);
|
||||
dbus_connection_add_filter(session_bus, message_handler, NULL, NULL);
|
||||
added_signal_match = session_bus;
|
||||
}
|
||||
|
|
|
|||
2
glfw/linux_notify.h
vendored
2
glfw/linux_notify.h
vendored
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
typedef unsigned long long notification_id_type;
|
||||
typedef void (*GLFWDBusnotificationcreatedfun)(notification_id_type, uint32_t, void*);
|
||||
typedef void (*GLFWDBusnotificationactivatedfun)(uint32_t, const char*);
|
||||
typedef void (*GLFWDBusnotificationactivatedfun)(uint32_t, int, const char*);
|
||||
notification_id_type
|
||||
glfw_dbus_send_user_notification(const char *app_name, const char* icon, const char *summary, const char *body, const char *action_name, int32_t timeout, int urgency, GLFWDBusnotificationcreatedfun, void*);
|
||||
void
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@
|
|||
from .key_encoding import get_name_to_functional_number_map
|
||||
from .keys import Mappings
|
||||
from .layout.base import set_layout_options
|
||||
from .notify import notification_activated
|
||||
from .notifications import NotificationManager
|
||||
from .options.types import Options, nullable_colors
|
||||
from .options.utils import MINIMUM_FONT_SIZE, KeyboardMode, KeyDefinition
|
||||
from .os_window_size import initial_window_size_func
|
||||
|
|
@ -372,13 +372,11 @@ def __init__(
|
|||
DumpCommands(args) if args.dump_commands or args.dump_bytes else None,
|
||||
talk_fd, listen_fd,
|
||||
)
|
||||
set_boss(self)
|
||||
self.args: CLIOptions = args
|
||||
self.mouse_handler: Optional[Callable[[WindowSystemMouseEvent], None]] = None
|
||||
set_boss(self)
|
||||
self.mappings: Mappings = Mappings(global_shortcuts, self.refresh_active_tab_bar)
|
||||
if is_macos:
|
||||
from .fast_data_types import cocoa_set_notification_activated_callback
|
||||
cocoa_set_notification_activated_callback(notification_activated)
|
||||
self.notification_manager: NotificationManager = NotificationManager()
|
||||
|
||||
def startup_first_child(self, os_window_id: Optional[int], startup_sessions: Iterable[Session] = ()) -> None:
|
||||
si = startup_sessions or create_sessions(get_options(), self.args, default_session=get_options().startup_session)
|
||||
|
|
@ -538,7 +536,9 @@ def get_matches(location: str, query: str, candidates: Set[int]) -> Set[int]:
|
|||
if q:
|
||||
yield q
|
||||
|
||||
def set_active_window(self, window: Window, switch_os_window_if_needed: bool = False, for_keep_focus: bool = False) -> Optional[int]:
|
||||
def set_active_window(
|
||||
self, window: Window, switch_os_window_if_needed: bool = False, for_keep_focus: bool = False, activation_token: str = ''
|
||||
) -> Optional[int]:
|
||||
for os_window_id, tm in self.os_window_map.items():
|
||||
for tab in tm:
|
||||
for w in tab:
|
||||
|
|
@ -546,8 +546,8 @@ def set_active_window(self, window: Window, switch_os_window_if_needed: bool = F
|
|||
if tab is not self.active_tab:
|
||||
tm.set_active_tab(tab, for_keep_focus=window.tabref() if for_keep_focus else None)
|
||||
tab.set_active_window(w, for_keep_focus=window if for_keep_focus else None)
|
||||
if switch_os_window_if_needed and current_focused_os_window_id() != os_window_id:
|
||||
focus_os_window(os_window_id, True)
|
||||
if activation_token or (switch_os_window_if_needed and current_focused_os_window_id() != os_window_id):
|
||||
focus_os_window(os_window_id, True, activation_token)
|
||||
return os_window_id
|
||||
return None
|
||||
|
||||
|
|
@ -2750,14 +2750,8 @@ def on_monitored_pid_death(self, pid: int, exit_status: int) -> None:
|
|||
except Exception as e:
|
||||
log_error(f'Failed to process update check data {raw!r}, with error: {e}')
|
||||
|
||||
def dbus_notification_callback(self, activated: bool, a: int, b: Union[int, str]) -> None:
|
||||
from .notify import dbus_notification_activated, dbus_notification_created
|
||||
if activated:
|
||||
assert isinstance(b, str)
|
||||
dbus_notification_activated(a, b)
|
||||
else:
|
||||
assert isinstance(b, int)
|
||||
dbus_notification_created(a, b)
|
||||
def dbus_notification_callback(self, event_type: str, a: int, b: Union[int, str]) -> None:
|
||||
self.notification_manager.desktop_integration.dispatch_event_from_desktop(event_type, a, b)
|
||||
|
||||
def show_bad_config_lines(self, bad_lines: Iterable[BadLine], misc_errors: Iterable[str] = ()) -> None:
|
||||
|
||||
|
|
@ -2984,19 +2978,7 @@ def set_background_image(self, path: Optional[str], os_windows: Tuple[int, ...],
|
|||
|
||||
# Can be called with kitty -o "map f1 send_test_notification"
|
||||
def send_test_notification(self) -> None:
|
||||
from .notify import notify
|
||||
now = monotonic()
|
||||
ident = f'test-notify-{now}'
|
||||
notify(f'Test {now}', f'At: {now}', identifier=ident, subtitle=f'Test subtitle {now}')
|
||||
|
||||
def notification_activated(self, identifier: str, window_id: int, focus: bool, report: bool) -> None:
|
||||
w = self.window_id_map.get(window_id)
|
||||
if w is None:
|
||||
return
|
||||
if focus:
|
||||
self.set_active_window(w, switch_os_window_if_needed=True)
|
||||
if report:
|
||||
w.report_notification_activated(identifier)
|
||||
self.notification_manager.send_test_notification()
|
||||
|
||||
@ac('debug', 'Show the environment variables that the kitty process sees')
|
||||
def show_kitty_env_vars(self) -> None:
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ def __init__(self, window_id: int) -> None:
|
|||
def parse_osc_5522(self, data: memoryview) -> None:
|
||||
import base64
|
||||
|
||||
from .notify import sanitize_id
|
||||
from .notifications import sanitize_id
|
||||
idx = find_in_memoryview(data, ord(b';'))
|
||||
if idx > -1:
|
||||
metadata = str(data[:idx], "utf-8", "replace")
|
||||
|
|
|
|||
|
|
@ -555,6 +555,9 @@ def dbus_send_notification(
|
|||
pass
|
||||
|
||||
|
||||
def dbus_close_notification(dbus_notification_id: int) -> bool: ...
|
||||
|
||||
|
||||
def cocoa_send_notification(
|
||||
identifier: Optional[str],
|
||||
title: str,
|
||||
|
|
|
|||
2
kitty/glfw-wrapper.h
generated
2
kitty/glfw-wrapper.h
generated
|
|
@ -1699,7 +1699,7 @@ typedef bool (* GLFWcocoatogglefullscreenfun)(GLFWwindow*);
|
|||
typedef void (* GLFWcocoarenderframefun)(GLFWwindow*);
|
||||
typedef void (*GLFWwaylandframecallbackfunc)(unsigned long long id);
|
||||
typedef void (*GLFWDBusnotificationcreatedfun)(unsigned long long, uint32_t, void*);
|
||||
typedef void (*GLFWDBusnotificationactivatedfun)(uint32_t, const char*);
|
||||
typedef void (*GLFWDBusnotificationactivatedfun)(uint32_t, int, const char*);
|
||||
typedef int (*glfwInit_func)(monotonic_t);
|
||||
GFW_EXTERN glfwInit_func glfwInit_impl;
|
||||
#define glfwInit glfwInit_impl
|
||||
|
|
|
|||
25
kitty/glfw.c
25
kitty/glfw.c
|
|
@ -1420,9 +1420,14 @@ error_callback(int error, const char* description) {
|
|||
|
||||
#ifndef __APPLE__
|
||||
static void
|
||||
dbus_user_notification_activated(uint32_t notification_id, const char* action) {
|
||||
dbus_user_notification_activated(uint32_t notification_id, int type, const char* action) {
|
||||
unsigned long nid = notification_id;
|
||||
call_boss(dbus_notification_callback, "Oks", Py_True, nid, action);
|
||||
const char *stype = "activated";
|
||||
switch (type) {
|
||||
case 0: stype = "closed"; break;
|
||||
case 1: stype = "activation_token"; break;
|
||||
}
|
||||
call_boss(dbus_notification_callback, "sks", stype, nid, action);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
@ -2064,7 +2069,7 @@ request_frame_render(OSWindow *w) {
|
|||
void
|
||||
dbus_notification_created_callback(unsigned long long notification_id, uint32_t new_notification_id, void* data UNUSED) {
|
||||
unsigned long new_id = new_notification_id;
|
||||
call_boss(dbus_notification_callback, "OKk", Py_False, notification_id, new_id);
|
||||
call_boss(dbus_notification_callback, "sKk", "created", notification_id, new_id);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
|
|
@ -2080,6 +2085,19 @@ dbus_send_notification(PyObject *self UNUSED, PyObject *args) {
|
|||
return PyLong_FromUnsignedLongLong(notification_id);
|
||||
}
|
||||
|
||||
static PyObject*
|
||||
dbus_close_notification(PyObject *self UNUSED, PyObject *args) {
|
||||
unsigned int id;
|
||||
if (!PyArg_ParseTuple(args, "I", &id)) return NULL;
|
||||
if (!glfwDBusUserNotify) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Failed to load glfwDBusUserNotify, did you call glfw_init?");
|
||||
return NULL;
|
||||
}
|
||||
if (glfwDBusUserNotify("", "", "", "", "", -9999, -9999, NULL, &id)) Py_RETURN_TRUE;
|
||||
Py_RETURN_FALSE;
|
||||
}
|
||||
|
||||
|
||||
#endif
|
||||
|
||||
static PyObject*
|
||||
|
|
@ -2244,6 +2262,7 @@ static PyMethodDef module_methods[] = {
|
|||
METHODB(strip_csi, METH_O),
|
||||
#ifndef __APPLE__
|
||||
METHODB(dbus_send_notification, METH_VARARGS),
|
||||
METHODB(dbus_close_notification, METH_VARARGS),
|
||||
#else
|
||||
{"cocoa_recreate_global_menu", (PyCFunction)py_recreate_global_menu, METH_NOARGS, ""},
|
||||
{"cocoa_clear_global_shortcuts", (PyCFunction)py_clear_global_shortcuts, METH_NOARGS, ""},
|
||||
|
|
|
|||
550
kitty/notifications.py
Normal file
550
kitty/notifications.py
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
#!/usr/bin/env python
|
||||
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from enum import Enum
|
||||
from itertools import count
|
||||
from typing import Any, Dict, FrozenSet, List, NamedTuple, Optional, Tuple, Union
|
||||
|
||||
from .constants import is_macos, logo_png_file
|
||||
from .fast_data_types import ESC_OSC, current_focused_os_window_id, get_boss
|
||||
from .types import run_once
|
||||
from .typing import WindowType
|
||||
from .utils import get_custom_window_icon, log_error, sanitize_control_codes
|
||||
|
||||
|
||||
class Urgency(Enum):
|
||||
Low: int = 0
|
||||
Normal: int = 1
|
||||
Critical: int = 2
|
||||
|
||||
|
||||
class PayloadType(Enum):
|
||||
unknown = ''
|
||||
title = 'title'
|
||||
body = 'body'
|
||||
query = '?'
|
||||
close = 'close'
|
||||
|
||||
@property
|
||||
def is_text(self) -> bool:
|
||||
return self in (PayloadType.title, PayloadType.body, PayloadType.close, PayloadType.query)
|
||||
|
||||
|
||||
class OnlyWhen(Enum):
|
||||
unset = ''
|
||||
always = 'always'
|
||||
unfocused = 'unfocused'
|
||||
invisible = 'invisible'
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
focus = 'focus'
|
||||
report = 'report'
|
||||
|
||||
|
||||
class DataStore:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buf: List[bytes] = []
|
||||
|
||||
def __call__(self, data: bytes) -> None:
|
||||
self.buf.append(data)
|
||||
|
||||
def finalise(self) -> bytes:
|
||||
return b''.join(self.buf)
|
||||
|
||||
|
||||
class EncodedDataStore:
|
||||
|
||||
def __init__(self, data_store: DataStore) -> None:
|
||||
self.current_leftover_bytes = memoryview(b'')
|
||||
self.data_store = data_store
|
||||
|
||||
def add_unencoded_data(self, data: Union[str, bytes]) -> None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode('utf-8')
|
||||
self.flush_encoded_data()
|
||||
self.data_store(data)
|
||||
|
||||
def add_base64_data(self, data: Union[str, bytes]) -> None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode('ascii')
|
||||
|
||||
def write_saving_leftover_bytes(data: bytes) -> None:
|
||||
if len(data) == 0:
|
||||
return
|
||||
extra = len(data) % 4
|
||||
if extra > 0:
|
||||
mv = memoryview(data)
|
||||
self.current_leftover_bytes = memoryview(bytes(mv[-extra:]))
|
||||
mv = mv[:-extra]
|
||||
if len(mv) > 0:
|
||||
self._write_base64_data(mv)
|
||||
else:
|
||||
self._write_base64_data(data)
|
||||
|
||||
if len(self.current_leftover_bytes) > 0:
|
||||
extra = 4 - len(self.current_leftover_bytes)
|
||||
if len(data) >= extra:
|
||||
self._write_base64_data(memoryview(bytes(self.current_leftover_bytes) + data[:extra]))
|
||||
self.current_leftover_bytes = memoryview(b'')
|
||||
data = memoryview(data)[extra:]
|
||||
write_saving_leftover_bytes(data)
|
||||
else:
|
||||
self.current_leftover_bytes = memoryview(bytes(self.current_leftover_bytes) + data)
|
||||
else:
|
||||
write_saving_leftover_bytes(data)
|
||||
|
||||
def _write_base64_data(self, b: bytes) -> None:
|
||||
from base64 import standard_b64decode
|
||||
d = standard_b64decode(b)
|
||||
self.data_store(d)
|
||||
|
||||
def flush_encoded_data(self) -> None:
|
||||
b = self.current_leftover_bytes
|
||||
self.current_leftover_bytes = memoryview(b'')
|
||||
padding = 4 - len(b)
|
||||
if padding in (1, 2):
|
||||
self._write_base64_data(memoryview(bytes(b) + b'=' * padding))
|
||||
|
||||
def finalise(self) -> bytes:
|
||||
self.flush_encoded_data()
|
||||
return self.data_store.finalise()
|
||||
|
||||
|
||||
def limit_size(x: str) -> str:
|
||||
if len(x) > 1024:
|
||||
x = x[:1024]
|
||||
return x
|
||||
|
||||
|
||||
class NotificationCommand:
|
||||
|
||||
done: bool = True
|
||||
identifier: str = ''
|
||||
channel_id: int = 0
|
||||
desktop_notification_id: int = -1
|
||||
title: str = ''
|
||||
body: str = ''
|
||||
actions: FrozenSet[Action] = frozenset((Action.focus,))
|
||||
only_when: OnlyWhen = OnlyWhen.unset
|
||||
urgency: Optional[Urgency] = None
|
||||
close_response_requested: bool = False
|
||||
|
||||
# payload handling
|
||||
current_payload_type: PayloadType = PayloadType.title
|
||||
current_payload_buffer: Optional[EncodedDataStore] = None
|
||||
|
||||
# desktop integration specific fields
|
||||
created_by_desktop: bool = False
|
||||
activation_token: str = ''
|
||||
|
||||
@property
|
||||
def report_requested(self) -> bool:
|
||||
return Action.report in self.actions
|
||||
|
||||
@property
|
||||
def focus_requested(self) -> bool:
|
||||
return Action.focus in self.actions
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f'NotificationCommand(identifier={self.identifier!r}, title={self.title!r}, body={self.body!r},'
|
||||
f'actions={self.actions}, done={self.done!r}, urgency={self.urgency})')
|
||||
|
||||
def parse_metadata(self, metadata: str, prev: 'NotificationCommand') -> Tuple[PayloadType, bool]:
|
||||
payload_type = PayloadType.title
|
||||
payload_is_encoded = False
|
||||
for part in metadata.split(':'):
|
||||
k, v = part.split('=', 1)
|
||||
if k == 'p':
|
||||
try:
|
||||
payload_type = PayloadType(v)
|
||||
except ValueError:
|
||||
payload_type = PayloadType.unknown
|
||||
elif k == 'i':
|
||||
self.identifier = sanitize_id(v)
|
||||
elif k == 'e':
|
||||
payload_is_encoded = v == '1'
|
||||
elif k == 'd':
|
||||
self.done = v != '0'
|
||||
elif k == 'a':
|
||||
for ax in v.split(','):
|
||||
if remove := ax.startswith('-'):
|
||||
ax = ax.lstrip('+-')
|
||||
try:
|
||||
ac = Action(ax)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if remove:
|
||||
self.actions -= {ac}
|
||||
else:
|
||||
self.actions = self.actions.union({ac})
|
||||
elif k == 'o':
|
||||
with suppress(ValueError):
|
||||
self.only_when = OnlyWhen(v)
|
||||
elif k == 'u':
|
||||
with suppress(Exception):
|
||||
self.urgency = Urgency(int(v))
|
||||
if not prev.done and prev.identifier == self.identifier:
|
||||
self.actions = prev.actions.union(self.actions)
|
||||
self.title = prev.title
|
||||
self.body = prev.body
|
||||
if self.only_when is OnlyWhen.unset:
|
||||
self.only_when = prev.only_when
|
||||
if self.urgency is None:
|
||||
self.urgency = prev.urgency
|
||||
|
||||
return payload_type, payload_is_encoded
|
||||
|
||||
def create_payload_buffer(self, payload_type: PayloadType) -> EncodedDataStore:
|
||||
self.current_payload_type = payload_type
|
||||
return EncodedDataStore(DataStore())
|
||||
|
||||
def set_payload(self, payload_type: PayloadType, payload_is_encoded: bool, payload: str, prev_cmd: 'NotificationCommand') -> None:
|
||||
if prev_cmd.current_payload_type is payload_type:
|
||||
self.current_payload_type = payload_type
|
||||
self.current_payload_buffer = prev_cmd.current_payload_buffer
|
||||
prev_cmd.current_payload_buffer = None
|
||||
else:
|
||||
if prev_cmd.current_payload_buffer:
|
||||
self.current_payload_type = prev_cmd.current_payload_type
|
||||
self.commit_data(prev_cmd.current_payload_buffer.finalise())
|
||||
if self.current_payload_buffer is None:
|
||||
self.current_payload_buffer = self.create_payload_buffer(payload_type)
|
||||
if payload_is_encoded:
|
||||
self.current_payload_buffer.add_base64_data(payload)
|
||||
else:
|
||||
self.current_payload_buffer.add_unencoded_data(payload)
|
||||
|
||||
def commit_data(self, data: bytes) -> None:
|
||||
if not data:
|
||||
return
|
||||
if self.current_payload_type.is_text:
|
||||
text = data.decode('utf-8', 'replace')
|
||||
if self.current_payload_type is PayloadType.title:
|
||||
self.title = limit_size(self.title + text)
|
||||
elif self.current_payload_type is PayloadType.body:
|
||||
self.body = limit_size(self.body + text)
|
||||
|
||||
def finalise(self) -> None:
|
||||
if self.current_payload_buffer:
|
||||
self.commit_data(self.current_payload_buffer.finalise())
|
||||
self.current_payload_buffer = None
|
||||
|
||||
|
||||
class DesktopIntegration:
|
||||
|
||||
def __init__(self, notification_manager: 'NotificationManager'):
|
||||
self.notification_manager = notification_manager
|
||||
self.initialize()
|
||||
|
||||
def initialize(self) -> None:
|
||||
pass
|
||||
|
||||
def dispatch_event_from_desktop(self, *a: Any) -> None:
|
||||
raise NotImplementedError('Implement me in subclass')
|
||||
|
||||
def close_notification(self, desktop_notification_id: int) -> bool:
|
||||
raise NotImplementedError('Implement me in subclass')
|
||||
|
||||
def notify(self,
|
||||
title: str,
|
||||
body: str,
|
||||
timeout: int = -1,
|
||||
application: str = 'kitty',
|
||||
icon: bool = True,
|
||||
subtitle: Optional[str] = None,
|
||||
urgency: Urgency = Urgency.Normal,
|
||||
) -> int:
|
||||
raise NotImplementedError('Implement me in subclass')
|
||||
|
||||
|
||||
class MacOSIntegration(DesktopIntegration):
|
||||
|
||||
def initialize(self) -> None:
|
||||
from .fast_data_types import cocoa_set_notification_activated_callback
|
||||
self.id_counter = count()
|
||||
cocoa_set_notification_activated_callback(self.notification_activated)
|
||||
|
||||
def notify(self,
|
||||
title: str,
|
||||
body: str,
|
||||
timeout: int = -1,
|
||||
application: str = 'kitty',
|
||||
icon: bool = True,
|
||||
subtitle: Optional[str] = None,
|
||||
urgency: Urgency = Urgency.Normal,
|
||||
) -> int:
|
||||
desktop_notification_id = next(self.id_counter)
|
||||
from .fast_data_types import cocoa_send_notification
|
||||
cocoa_send_notification(str(desktop_notification_id), title, body, subtitle, urgency.value)
|
||||
return desktop_notification_id
|
||||
|
||||
def notification_activated(self, ident: str) -> None:
|
||||
try:
|
||||
desktop_notification_id = int(ident)
|
||||
except Exception:
|
||||
log_error(f'Got unexpected notification activated event with id: {ident!r} from cocoa')
|
||||
else:
|
||||
self.notification_manager.notification_activated(desktop_notification_id)
|
||||
|
||||
|
||||
class FreeDesktopIntegration(DesktopIntegration):
|
||||
|
||||
def close_notification(self, desktop_notification_id: int) -> bool:
|
||||
from .fast_data_types import dbus_close_notification
|
||||
return dbus_close_notification(desktop_notification_id)
|
||||
|
||||
def dispatch_event_from_desktop(self, *args: Any) -> None:
|
||||
event_type: str = args[0]
|
||||
dbus_notification_id: int = args[1]
|
||||
if event_type == 'created':
|
||||
self.notification_manager.notification_created(dbus_notification_id)
|
||||
elif event_type == 'activation_token':
|
||||
token: str = args[2]
|
||||
self.notification_manager.notification_activation_token_received(dbus_notification_id, token)
|
||||
elif event_type == 'activated':
|
||||
self.notification_manager.notification_activated(dbus_notification_id)
|
||||
elif event_type == 'closed':
|
||||
self.notification_manager.notification_closed(dbus_notification_id)
|
||||
|
||||
def notify(self,
|
||||
title: str,
|
||||
body: str,
|
||||
timeout: int = -1,
|
||||
application: str = 'kitty',
|
||||
icon: bool = True,
|
||||
subtitle: Optional[str] = None,
|
||||
urgency: Urgency = Urgency.Normal,
|
||||
) -> int:
|
||||
icf = ''
|
||||
if icon is True:
|
||||
icf = get_custom_window_icon()[1] or logo_png_file
|
||||
from .fast_data_types import dbus_send_notification
|
||||
return dbus_send_notification(application, icf, title, body, 'Click to see changes', timeout, urgency.value)
|
||||
|
||||
|
||||
class UIState(NamedTuple):
|
||||
has_keyboard_focus: bool
|
||||
is_visible: bool
|
||||
|
||||
|
||||
class Channel:
|
||||
|
||||
def window_for_id(self, channel_id: int) -> Optional[WindowType]:
|
||||
boss = get_boss()
|
||||
if channel_id:
|
||||
return boss.window_id_map.get(channel_id)
|
||||
return boss.active_window
|
||||
|
||||
def ui_state(self, channel_id: int) -> UIState:
|
||||
has_focus = is_visible = False
|
||||
boss = get_boss()
|
||||
if w := self.window_for_id(channel_id):
|
||||
has_focus = w.is_active and w.os_window_id == current_focused_os_window_id()
|
||||
# window is in the active OS window and the active tab and is visible in the tab layout
|
||||
is_visible = w.os_window_id == current_focused_os_window_id() and w.tabref() is boss.active_tab and w.is_visible_in_layout
|
||||
return UIState(has_focus, is_visible)
|
||||
|
||||
def send(self, channel_id: int, osc_escape_code: str) -> bool:
|
||||
if w := self.window_for_id(channel_id):
|
||||
if not w.destroyed:
|
||||
w.screen.send_escape_code_to_child(ESC_OSC, osc_escape_code)
|
||||
return True
|
||||
return False
|
||||
|
||||
def focus(self, channel_id: int, activation_token: str) -> None:
|
||||
boss = get_boss()
|
||||
if w := self.window_for_id(channel_id):
|
||||
boss.set_active_window(w, switch_os_window_if_needed=True, activation_token=activation_token)
|
||||
|
||||
|
||||
def sanitize_text(x: str) -> str:
|
||||
return sanitize_control_codes(x)
|
||||
|
||||
@run_once
|
||||
def sanitize_identifier_pat() -> 're.Pattern[str]':
|
||||
return re.compile(r'[^a-zA-Z0-9-_+.]+')
|
||||
|
||||
|
||||
def sanitize_id(v: str) -> str:
|
||||
return sanitize_identifier_pat().sub('', v)
|
||||
|
||||
|
||||
class Log:
|
||||
def __call__(self, *a: Any, **kw: str) -> None:
|
||||
log_error(*a, **kw)
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
desktop_integration: Optional[DesktopIntegration] = None,
|
||||
channel: Channel = Channel(),
|
||||
log: Log = Log(),
|
||||
):
|
||||
if desktop_integration is None:
|
||||
self.desktop_integration = MacOSIntegration(self) if is_macos else FreeDesktopIntegration(self)
|
||||
else:
|
||||
self.desktop_integration = desktop_integration
|
||||
self.channel = channel
|
||||
self.log = log
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.new_version_notification_id = ''
|
||||
self.in_progress_notification_commands: 'OrderedDict[int, NotificationCommand]' = OrderedDict()
|
||||
self.in_progress_notification_commands_by_client_id: Dict[str, NotificationCommand] = {}
|
||||
self.pending_commands: Dict[int, NotificationCommand] = {}
|
||||
|
||||
def dispatch_event_from_desktop(self, *args: Any) -> None:
|
||||
self.desktop_integration.dispatch_event_from_desktop(*args)
|
||||
|
||||
def notification_created(self, desktop_notification_id: int) -> None:
|
||||
if n := self.in_progress_notification_commands.get(desktop_notification_id):
|
||||
n.created_by_desktop = True
|
||||
|
||||
def notification_activation_token_received(self, desktop_notification_id: int, token: str) -> None:
|
||||
if n := self.in_progress_notification_commands.get(desktop_notification_id):
|
||||
n.activation_token = token
|
||||
|
||||
def notification_activated(self, desktop_notification_id: int) -> None:
|
||||
if n := self.in_progress_notification_commands.get(desktop_notification_id):
|
||||
if n.focus_requested:
|
||||
self.channel.focus(n.channel_id, n.activation_token)
|
||||
if n.report_requested:
|
||||
if n.identifier:
|
||||
self.channel.send(n.channel_id, f'99;i={n.identifier}')
|
||||
if self.new_version_notification_id and n.identifier == self.new_version_notification_id:
|
||||
self.new_version_notification_id = ''
|
||||
from .update_check import notification_activated
|
||||
notification_activated()
|
||||
|
||||
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)
|
||||
|
||||
def send_test_notification(self) -> None:
|
||||
boss = get_boss()
|
||||
if w := boss.active_window:
|
||||
from time import monotonic
|
||||
cmd = NotificationCommand()
|
||||
now = monotonic()
|
||||
cmd.title = f'Test {now}'
|
||||
cmd.body = f'At: {now}'
|
||||
self.notify_with_command(cmd, w.id)
|
||||
|
||||
def send_new_version_notification(self, version: str) -> None:
|
||||
from .short_uuid import uuid4
|
||||
cmd = NotificationCommand()
|
||||
cmd.title = 'kitty update available!'
|
||||
cmd.body = f'kitty version {version} released'
|
||||
self.new_version_notification_id = cmd.identifier = uuid4()
|
||||
self.notify_with_command(cmd, 0)
|
||||
|
||||
def is_notification_allowed(self, cmd: NotificationCommand, channel_id: int) -> bool:
|
||||
if cmd.only_when is not OnlyWhen.always and cmd.only_when is not OnlyWhen.unset:
|
||||
ui_state = self.channel.ui_state(channel_id)
|
||||
if ui_state.has_keyboard_focus:
|
||||
return False
|
||||
if cmd.only_when is OnlyWhen.invisible and ui_state.is_visible:
|
||||
return False
|
||||
return True
|
||||
|
||||
def notify_with_command(self, cmd: NotificationCommand, channel_id: int) -> Optional[int]:
|
||||
cmd.channel_id = channel_id
|
||||
cmd.finalise()
|
||||
title = cmd.title or cmd.body
|
||||
body = cmd.body if cmd.title else ''
|
||||
if not title or not self.is_notification_allowed(cmd, channel_id):
|
||||
return None
|
||||
urgency = Urgency.Normal if cmd.urgency is None else cmd.urgency
|
||||
desktop_notification_id = self.desktop_integration.notify(title=sanitize_text(title), body=sanitize_text(body), urgency=urgency)
|
||||
self.register_in_progress_notification(cmd, desktop_notification_id)
|
||||
return desktop_notification_id
|
||||
|
||||
def register_in_progress_notification(self, cmd: NotificationCommand, desktop_notification_id: int) -> None:
|
||||
cmd.desktop_notification_id = desktop_notification_id
|
||||
self.in_progress_notification_commands[desktop_notification_id] = cmd
|
||||
if cmd.identifier:
|
||||
self.in_progress_notification_commands_by_client_id[cmd.identifier] = cmd
|
||||
if len(self.in_progress_notification_commands) > 128:
|
||||
_, cmd = self.in_progress_notification_commands.popitem(False)
|
||||
self.in_progress_notification_commands_by_client_id.pop(cmd.identifier, None)
|
||||
|
||||
def parse_notification_cmd(self, prev_cmd: NotificationCommand, channel_id: int, raw: str) -> Optional[NotificationCommand]:
|
||||
metadata, payload = raw.partition(';')[::2]
|
||||
cmd = NotificationCommand()
|
||||
try:
|
||||
payload_type, payload_is_encoded = cmd.parse_metadata(metadata, prev_cmd)
|
||||
except Exception:
|
||||
self.log('Malformed metadata section in OSC 99: ' + metadata)
|
||||
return None
|
||||
if payload_type is PayloadType.query:
|
||||
actions = ','.join(x.value for x in Action)
|
||||
when = ','.join(x.value for x in OnlyWhen if x.value)
|
||||
urgency = ','.join(str(x.value) for x in Urgency)
|
||||
i = f'i={cmd.identifier or "0"}:'
|
||||
p = ','.join(x.value for x in PayloadType if x.value)
|
||||
self.channel.send(channel_id, f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}')
|
||||
return None
|
||||
if payload_type is PayloadType.close:
|
||||
if payload_is_encoded:
|
||||
from base64 import standard_b64decode
|
||||
try:
|
||||
payload = standard_b64decode(payload).decode('utf-8')
|
||||
except Exception:
|
||||
self.log('Malformed OSC 99 close command: payload is not base64 encoded UTF-8 text')
|
||||
if cmd.identifier:
|
||||
to_close = self.in_progress_notification_commands_by_client_id.get(cmd.identifier)
|
||||
if to_close:
|
||||
if payload == 'notify':
|
||||
to_close.close_response_requested = True
|
||||
if not self.desktop_integration.close_notification(to_close.desktop_notification_id):
|
||||
if to_close.close_response_requested:
|
||||
self.send_closed_response(to_close.channel_id, to_close.identifier)
|
||||
self.purge_notification(to_close)
|
||||
else:
|
||||
self.send_closed_response(channel_id, cmd.identifier)
|
||||
return None
|
||||
|
||||
if payload_type is PayloadType.unknown:
|
||||
self.log(f'OSC 99: unknown payload type: {payload_type}, ignoring payload')
|
||||
payload = ''
|
||||
|
||||
cmd.set_payload(payload_type, payload_is_encoded, payload, prev_cmd)
|
||||
return cmd
|
||||
|
||||
def send_closed_response(self, channel_id: int, client_id: str) -> None:
|
||||
self.channel.send(channel_id, f'99;i={client_id}:p=close;')
|
||||
|
||||
def purge_notification(self, cmd: NotificationCommand) -> None:
|
||||
self.in_progress_notification_commands_by_client_id.pop(cmd.identifier, None)
|
||||
self.in_progress_notification_commands.pop(cmd.desktop_notification_id, None)
|
||||
|
||||
def handle_notification_cmd(self, channel_id: int, osc_code: int, raw: str) -> None:
|
||||
if osc_code == 99:
|
||||
cmd = self.pending_commands.pop(channel_id, None) or NotificationCommand()
|
||||
q = self.parse_notification_cmd(cmd, channel_id, raw)
|
||||
if q is not None:
|
||||
if q.done:
|
||||
self.notify_with_command(q, channel_id)
|
||||
else:
|
||||
self.pending_commands[channel_id] = q
|
||||
elif osc_code == 9:
|
||||
n = NotificationCommand()
|
||||
n.title = raw
|
||||
self.notify_with_command(n, channel_id)
|
||||
elif osc_code == 777:
|
||||
n = NotificationCommand()
|
||||
parts = raw.split(';', 1)
|
||||
n.title, n.body = parts[0], (parts[1] if len(parts) > 1 else '')
|
||||
self.notify_with_command(n, channel_id)
|
||||
328
kitty/notify.py
328
kitty/notify.py
|
|
@ -1,328 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# License: GPLv3 Copyright: 2019, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
import re
|
||||
from base64 import standard_b64decode
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from enum import Enum
|
||||
from itertools import count
|
||||
from typing import Callable, Dict, FrozenSet, Optional
|
||||
|
||||
from .constants import is_macos, logo_png_file
|
||||
from .fast_data_types import current_focused_os_window_id, get_boss
|
||||
from .types import run_once
|
||||
from .utils import get_custom_window_icon, log_error
|
||||
|
||||
|
||||
class Urgency(Enum):
|
||||
Low: int = 0
|
||||
Normal: int = 1
|
||||
Critical: int = 2
|
||||
|
||||
|
||||
if is_macos:
|
||||
from .fast_data_types import cocoa_send_notification
|
||||
|
||||
def notify(
|
||||
title: str,
|
||||
body: str,
|
||||
timeout: int = 5000,
|
||||
application: str = 'kitty',
|
||||
icon: bool = True,
|
||||
identifier: Optional[str] = None,
|
||||
subtitle: Optional[str] = None,
|
||||
urgency: Urgency = Urgency.Normal,
|
||||
) -> None:
|
||||
cocoa_send_notification(identifier, title, body, subtitle, urgency.value)
|
||||
|
||||
else:
|
||||
|
||||
from .fast_data_types import dbus_send_notification
|
||||
|
||||
alloc_map: Dict[int, str] = {}
|
||||
identifier_map: Dict[str, int] = {}
|
||||
|
||||
def dbus_notification_created(alloc_id: int, notification_id: int) -> None:
|
||||
identifier = alloc_map.pop(alloc_id, None)
|
||||
if identifier is not None:
|
||||
identifier_map[identifier] = notification_id
|
||||
|
||||
def dbus_notification_activated(notification_id: int, action: str) -> None:
|
||||
rmap = {v: k for k, v in identifier_map.items()}
|
||||
identifier = rmap.get(notification_id)
|
||||
if identifier is not None:
|
||||
notification_activated(identifier)
|
||||
|
||||
def notify(
|
||||
title: str,
|
||||
body: str,
|
||||
timeout: int = -1,
|
||||
application: str = 'kitty',
|
||||
icon: bool = True,
|
||||
identifier: Optional[str] = None,
|
||||
subtitle: Optional[str] = None,
|
||||
urgency: Urgency = Urgency.Normal,
|
||||
) -> None:
|
||||
icf = ''
|
||||
if icon is True:
|
||||
icf = get_custom_window_icon()[1] or logo_png_file
|
||||
alloc_id = dbus_send_notification(application, icf, title, body, 'Click to see changes', timeout, urgency.value)
|
||||
if alloc_id and identifier is not None:
|
||||
alloc_map[alloc_id] = identifier
|
||||
|
||||
class NotifyImplementation:
|
||||
def __call__(self, title: str, body: str, identifier: str, urgency: Urgency = Urgency.Normal) -> None:
|
||||
notify(title, body, identifier=identifier, urgency=urgency)
|
||||
|
||||
notify_implementation = NotifyImplementation()
|
||||
|
||||
|
||||
class PayloadType(Enum):
|
||||
unknown = ''
|
||||
title = 'title'
|
||||
body = 'body'
|
||||
query = '?'
|
||||
close = 'close'
|
||||
|
||||
|
||||
class OnlyWhen(Enum):
|
||||
unset = ''
|
||||
always = 'always'
|
||||
unfocused = 'unfocused'
|
||||
invisible = 'invisible'
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
focus = 'focus'
|
||||
report = 'report'
|
||||
|
||||
|
||||
class NotificationCommand:
|
||||
|
||||
done: bool = True
|
||||
identifier: str = '0'
|
||||
title: str = ''
|
||||
body: str = ''
|
||||
actions: FrozenSet[Action] = frozenset((Action.focus,))
|
||||
only_when: OnlyWhen = OnlyWhen.unset
|
||||
urgency: Optional[Urgency] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f'NotificationCommand(identifier={self.identifier!r}, title={self.title!r}, body={self.body!r},'
|
||||
f'actions={self.actions}, done={self.done!r}, urgency={self.urgency})')
|
||||
|
||||
|
||||
def parse_osc_9(raw: str) -> NotificationCommand:
|
||||
ans = NotificationCommand()
|
||||
ans.title = raw
|
||||
return ans
|
||||
|
||||
|
||||
def parse_osc_777(raw: str) -> NotificationCommand:
|
||||
parts = raw.split(';', 1)
|
||||
ans = NotificationCommand()
|
||||
ans.title = parts[0]
|
||||
if len(parts) > 1:
|
||||
ans.body = parts[1]
|
||||
return ans
|
||||
|
||||
|
||||
@run_once
|
||||
def sanitize_identifier_pat() -> 're.Pattern[str]':
|
||||
return re.compile(r'[^a-zA-Z0-9-_+.]+')
|
||||
|
||||
|
||||
def sanitize_id(v: str) -> str:
|
||||
return sanitize_identifier_pat().sub('', v)
|
||||
|
||||
|
||||
class QueryResponse(Exception):
|
||||
|
||||
def __init__(self, response_string: str):
|
||||
super().__init__('not an error')
|
||||
self.response_string = response_string
|
||||
|
||||
|
||||
def parse_osc_99(raw: str, log_warnings: bool = True) -> NotificationCommand:
|
||||
cmd = NotificationCommand()
|
||||
metadata, payload = raw.partition(';')[::2]
|
||||
payload_is_encoded = False
|
||||
payload_type = PayloadType.title
|
||||
if metadata:
|
||||
for part in metadata.split(':'):
|
||||
try:
|
||||
k, v = part.split('=', 1)
|
||||
except Exception:
|
||||
if log_warnings:
|
||||
log_error('Malformed OSC 99: metadata is not key=value pairs')
|
||||
return cmd
|
||||
if k == 'p':
|
||||
try:
|
||||
payload_type = PayloadType(v)
|
||||
except ValueError:
|
||||
payload_type = PayloadType.unknown
|
||||
elif k == 'i':
|
||||
cmd.identifier = sanitize_id(v)
|
||||
elif k == 'e':
|
||||
payload_is_encoded = v == '1'
|
||||
elif k == 'd':
|
||||
cmd.done = v != '0'
|
||||
elif k == 'a':
|
||||
for ax in v.split(','):
|
||||
if remove := ax.startswith('-'):
|
||||
ax = ax.lstrip('+-')
|
||||
try:
|
||||
ac = Action(ax)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if remove:
|
||||
cmd.actions -= {ac}
|
||||
else:
|
||||
cmd.actions = cmd.actions.union({ac})
|
||||
elif k == 'o':
|
||||
with suppress(ValueError):
|
||||
cmd.only_when = OnlyWhen(v)
|
||||
elif k == 'u':
|
||||
with suppress(Exception):
|
||||
cmd.urgency = Urgency(int(v))
|
||||
if payload_type is PayloadType.query:
|
||||
actions = ','.join(x.value for x in Action)
|
||||
when = ','.join(x.value for x in OnlyWhen if x.value)
|
||||
urgency = ','.join(str(x.value) for x in Urgency)
|
||||
i = f'i={sanitize_id(cmd.identifier or "0")}:'
|
||||
p = ','.join(x.value for x in PayloadType if x.value)
|
||||
raise QueryResponse(f'99;{i}p=?;a={actions}:o={when}:u={urgency}:p={p}')
|
||||
|
||||
if payload_type in (PayloadType.title, PayloadType.body):
|
||||
if payload_is_encoded:
|
||||
try:
|
||||
payload = standard_b64decode(payload).decode('utf-8')
|
||||
except Exception:
|
||||
if log_warnings:
|
||||
log_error('Malformed OSC 99: payload is not base64 encoded UTF-8 text')
|
||||
return NotificationCommand()
|
||||
if payload_type is PayloadType.title:
|
||||
cmd.title = payload
|
||||
else:
|
||||
cmd.body = payload
|
||||
else:
|
||||
if log_warnings:
|
||||
log_error(f'OSC 99: unknown payload type: {payload_type}, ignoring payload')
|
||||
return cmd
|
||||
|
||||
|
||||
def limit_size(x: str) -> str:
|
||||
if len(x) > 1024:
|
||||
x = x[:1024]
|
||||
return x
|
||||
|
||||
|
||||
def merge_osc_99(prev: NotificationCommand, cmd: NotificationCommand) -> NotificationCommand:
|
||||
if prev.done or prev.identifier != cmd.identifier:
|
||||
return cmd
|
||||
cmd.actions = prev.actions.union(cmd.actions)
|
||||
cmd.title = limit_size(prev.title + cmd.title)
|
||||
cmd.body = limit_size(prev.body + cmd.body)
|
||||
if cmd.only_when is OnlyWhen.unset:
|
||||
cmd.only_when = prev.only_when
|
||||
if cmd.urgency is None:
|
||||
cmd.urgency = prev.urgency
|
||||
return cmd
|
||||
|
||||
|
||||
identifier_registry: "OrderedDict[str, RegisteredNotification]" = OrderedDict()
|
||||
id_counter = count()
|
||||
|
||||
|
||||
class RegisteredNotification:
|
||||
identifier: str
|
||||
window_id: int
|
||||
focus: bool = False
|
||||
report: bool = False
|
||||
|
||||
def __init__(self, cmd: NotificationCommand, window_id: int):
|
||||
self.window_id = window_id
|
||||
for x in cmd.actions:
|
||||
if x is Action.focus:
|
||||
self.focus = True
|
||||
elif x is Action.report:
|
||||
self.report = True
|
||||
self.identifier = cmd.identifier
|
||||
|
||||
|
||||
def register_identifier(identifier: str, cmd: NotificationCommand, window_id: int) -> None:
|
||||
identifier_registry[identifier] = RegisteredNotification(cmd, window_id)
|
||||
if len(identifier_registry) > 100:
|
||||
identifier_registry.popitem(False)
|
||||
|
||||
|
||||
def notification_activated(identifier: str, activated_implementation: Optional[Callable[[str, int, bool, bool], None]] = None) -> None:
|
||||
if identifier == 'new-version':
|
||||
from .update_check import notification_activated as do
|
||||
do()
|
||||
elif identifier.startswith('test-notify-'):
|
||||
log_error(f'Test notification {identifier} activated')
|
||||
else:
|
||||
r = identifier_registry.pop(identifier, None)
|
||||
if r is not None and (r.focus or r.report):
|
||||
if activated_implementation is None:
|
||||
get_boss().notification_activated(r.identifier, r.window_id, r.focus, r.report)
|
||||
else:
|
||||
activated_implementation(r.identifier, r.window_id, r.focus, r.report)
|
||||
|
||||
|
||||
def reset_registry() -> None:
|
||||
global id_counter
|
||||
identifier_registry.clear()
|
||||
id_counter = count()
|
||||
|
||||
|
||||
def notify_with_command(cmd: NotificationCommand, window_id: int, notify_implementation: NotifyImplementation = notify_implementation) -> None:
|
||||
title = cmd.title or cmd.body
|
||||
body = cmd.body if cmd.title else ''
|
||||
if not title:
|
||||
return
|
||||
urgency = Urgency.Normal if cmd.urgency is None else cmd.urgency
|
||||
if cmd.only_when is not OnlyWhen.always and cmd.only_when is not OnlyWhen.unset:
|
||||
w = get_boss().window_id_map.get(window_id)
|
||||
if w is None:
|
||||
return
|
||||
boss = get_boss()
|
||||
window_has_keyboard_focus = w.is_active and w.os_window_id == current_focused_os_window_id()
|
||||
if window_has_keyboard_focus:
|
||||
return
|
||||
if cmd.only_when is OnlyWhen.invisible:
|
||||
if w.os_window_id == current_focused_os_window_id() and w.tabref() is boss.active_tab and w.is_visible_in_layout:
|
||||
return # window is in the active OS window and the active tab and is visible in the tab layout
|
||||
if title:
|
||||
identifier = f'i{next(id_counter)}'
|
||||
notify_implementation(title, body, identifier, urgency)
|
||||
register_identifier(identifier, cmd, window_id)
|
||||
|
||||
|
||||
def handle_notification_cmd(
|
||||
osc_code: int,
|
||||
raw_data: str,
|
||||
window_id: int,
|
||||
prev_cmd: NotificationCommand,
|
||||
notify_implementation: NotifyImplementation = notify_implementation,
|
||||
log_warnings: bool = True,
|
||||
) -> Optional[NotificationCommand]:
|
||||
if osc_code == 99:
|
||||
cmd = merge_osc_99(prev_cmd, parse_osc_99(raw_data, log_warnings=log_warnings))
|
||||
if cmd.done:
|
||||
notify_with_command(cmd, window_id, notify_implementation)
|
||||
cmd = NotificationCommand()
|
||||
return cmd
|
||||
if osc_code == 9:
|
||||
cmd = parse_osc_9(raw_data)
|
||||
notify_with_command(cmd, window_id, notify_implementation)
|
||||
return cmd
|
||||
if osc_code == 777:
|
||||
cmd = parse_osc_777(raw_data)
|
||||
notify_with_command(cmd, window_id, notify_implementation)
|
||||
return cmd
|
||||
return None
|
||||
|
|
@ -11,7 +11,6 @@
|
|||
from .config import atomic_save
|
||||
from .constants import Version, cache_dir, clear_handled_signals, kitty_exe, version, website_url
|
||||
from .fast_data_types import add_timer, get_boss, monitor_pid
|
||||
from .notify import notify
|
||||
from .utils import log_error, open_url
|
||||
|
||||
CHANGELOG_URL = website_url('changelog')
|
||||
|
|
@ -37,11 +36,7 @@ def version_notification_log() -> str:
|
|||
|
||||
|
||||
def notify_new_version(release_version: Version) -> None:
|
||||
notify(
|
||||
'kitty update available!',
|
||||
'kitty version {} released'.format('.'.join(map(str, release_version))),
|
||||
identifier='new-version',
|
||||
)
|
||||
get_boss().notification_manager.send_new_version_notification('.'.join(map(str, release_version)))
|
||||
|
||||
|
||||
def get_released_version() -> str:
|
||||
|
|
|
|||
|
|
@ -90,16 +90,6 @@
|
|||
wakeup_main_loop,
|
||||
)
|
||||
from .keys import keyboard_mode_name, mod_mask
|
||||
from .notify import (
|
||||
NotificationCommand,
|
||||
NotifyImplementation,
|
||||
OnlyWhen,
|
||||
QueryResponse,
|
||||
Urgency,
|
||||
handle_notification_cmd,
|
||||
notify_with_command,
|
||||
sanitize_identifier_pat,
|
||||
)
|
||||
from .rgb import to_color
|
||||
from .terminfo import get_capabilities
|
||||
from .types import MouseEvent, OverlayType, WindowGeometry, ac, run_once
|
||||
|
|
@ -611,7 +601,6 @@ def __init__(
|
|||
self.current_remote_data: List[str] = []
|
||||
self.current_mouse_event_button = 0
|
||||
self.current_clipboard_read_ask: Optional[bool] = None
|
||||
self.prev_osc99_cmd = NotificationCommand()
|
||||
self.last_cmd_output_start_time = 0.
|
||||
self.open_url_handler: 'OpenUrlHandler' = None
|
||||
self.last_cmd_cmdline = ''
|
||||
|
|
@ -1078,12 +1067,7 @@ def desktop_notify(self, osc_code: int, raw_datab: memoryview) -> None:
|
|||
log_error(f'Ignoring unknown OSC 777: {raw_data}')
|
||||
return # unknown OSC 777
|
||||
raw_data = raw_data[len('notify;'):]
|
||||
try:
|
||||
cmd = handle_notification_cmd(osc_code, raw_data, self.id, self.prev_osc99_cmd)
|
||||
if cmd is not None and osc_code == 99:
|
||||
self.prev_osc99_cmd = cmd
|
||||
except QueryResponse as err:
|
||||
self.screen.send_escape_code_to_child(ESC_OSC, err.response_string)
|
||||
get_boss().notification_manager.handle_notification_cmd(self.id, osc_code, raw_data)
|
||||
|
||||
def on_mouse_event(self, event: Dict[str, Any]) -> bool:
|
||||
event['mods'] = event.get('mods', 0) & mod_mask
|
||||
|
|
@ -1244,10 +1228,6 @@ def report_color(self, code: str, col: Color) -> None:
|
|||
b |= b << 8
|
||||
self.screen.send_escape_code_to_child(ESC_OSC, f'{code};rgb:{r:04x}/{g:04x}/{b:04x}')
|
||||
|
||||
def report_notification_activated(self, identifier: str) -> None:
|
||||
identifier = sanitize_identifier_pat().sub('', identifier)
|
||||
self.screen.send_escape_code_to_child(ESC_OSC, f'99;i={identifier};')
|
||||
|
||||
def notify_child_of_resize(self) -> None:
|
||||
pty_size = self.last_reported_pty_size
|
||||
if pty_size[0] > -1 and self.screen.in_band_resize_notification:
|
||||
|
|
@ -1492,29 +1472,21 @@ def handle_cmd_end(self, exit_status: str = '') -> None:
|
|||
when, duration, action, notify_cmdline = opts.notify_on_cmd_finish
|
||||
|
||||
if last_cmd_output_duration >= duration and when != 'never':
|
||||
from .notifications import NotificationCommand, OnlyWhen
|
||||
cmd = NotificationCommand()
|
||||
cmd.title = 'kitty'
|
||||
s = self.last_cmd_cmdline.replace('\\\n', ' ')
|
||||
cmd.body = f'Command {s} finished with status: {exit_status}.\nClick to focus.'
|
||||
cmd.only_when = OnlyWhen(when)
|
||||
nm = get_boss().notification_manager
|
||||
if not nm.is_notification_allowed(cmd, self.id):
|
||||
return
|
||||
if action == 'notify':
|
||||
notify_with_command(cmd, self.id)
|
||||
nm.notify_with_command(cmd, self.id)
|
||||
elif action == 'bell':
|
||||
class Bell(NotifyImplementation):
|
||||
def __init__(self, window_id: int):
|
||||
self.window_id = window_id
|
||||
def __call__(self, title: str, body: str, identifier: str, urgency: Urgency = Urgency.Normal) -> None:
|
||||
w = get_boss().window_id_map.get(self.window_id)
|
||||
if w:
|
||||
w.screen.bell()
|
||||
notify_with_command(cmd, self.id, notify_implementation=Bell(self.id))
|
||||
self.screen.bell()
|
||||
elif action == 'command':
|
||||
class Run(NotifyImplementation):
|
||||
def __init__(self, last_cmd_cmdline: str):
|
||||
self.last_cmd_cmdline = last_cmd_cmdline
|
||||
def __call__(self, title: str, body: str, identifier: str, urgency: Urgency = Urgency.Normal) -> None:
|
||||
open_cmd([x.replace('%c', self.last_cmd_cmdline).replace('%s', exit_status) for x in notify_cmdline])
|
||||
notify_with_command(cmd, self.id, notify_implementation=Run(self.last_cmd_cmdline))
|
||||
open_cmd([x.replace('%c', self.last_cmd_cmdline).replace('%s', exit_status) for x in notify_cmdline])
|
||||
else:
|
||||
raise ValueError(f'Unknown action in option `notify_on_cmd_finish`: {action}')
|
||||
|
||||
|
|
|
|||
164
kitty_tests/notifications.py
Normal file
164
kitty_tests/notifications.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env python
|
||||
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
|
||||
from base64 import standard_b64encode
|
||||
from typing import Optional
|
||||
|
||||
from kitty.notifications import Channel, DesktopIntegration, NotificationManager, UIState, Urgency
|
||||
|
||||
from . import BaseTest
|
||||
|
||||
|
||||
def n(title='title', body='', urgency=Urgency.Normal, desktop_notification_id=1):
|
||||
return {'title': title, 'body': body, 'urgency': urgency, 'id': desktop_notification_id}
|
||||
|
||||
|
||||
class DesktopIntegration(DesktopIntegration):
|
||||
def initialize(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.notifications = []
|
||||
self.close_events = []
|
||||
self.counter = 0
|
||||
|
||||
def close_notification(self, desktop_notification_id: int) -> bool:
|
||||
self.close_events.append(desktop_notification_id)
|
||||
|
||||
def notify(self,
|
||||
title: str,
|
||||
body: str,
|
||||
timeout: int = -1,
|
||||
application: str = 'kitty',
|
||||
icon: bool = True,
|
||||
subtitle: Optional[str] = None,
|
||||
urgency: Urgency = Urgency.Normal,
|
||||
) -> int:
|
||||
self.counter += 1
|
||||
self.notifications.append(n(title, body, urgency, self.counter))
|
||||
return self.counter
|
||||
|
||||
|
||||
class Channel(Channel):
|
||||
|
||||
focused = visible = True
|
||||
|
||||
def __init__(self, *a):
|
||||
super().__init__(*a)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.responses = []
|
||||
self.focus_events = []
|
||||
|
||||
def ui_state(self, channel_id):
|
||||
return UIState(self.focused, self.visible)
|
||||
|
||||
def focus(self, channel_id: int, activation_token: str) -> None:
|
||||
self.focus_events.append(activation_token)
|
||||
|
||||
def send(self, channel_id: int, osc_escape_code: str) -> bool:
|
||||
self.responses.append(osc_escape_code)
|
||||
|
||||
|
||||
def do_test(self: 'TestNotifications') -> None:
|
||||
di = DesktopIntegration(None)
|
||||
ch = Channel()
|
||||
nm = NotificationManager(di, ch, lambda *a, **kw: None)
|
||||
di.notification_manager = nm
|
||||
|
||||
def reset():
|
||||
di.reset()
|
||||
ch.reset()
|
||||
nm.reset()
|
||||
|
||||
def h(raw_data, osc_code=99, channel_id=1):
|
||||
nm.handle_notification_cmd(channel_id, osc_code, raw_data)
|
||||
|
||||
def activate(which=0):
|
||||
n = di.notifications[which]
|
||||
nm.notification_activated(n['id'])
|
||||
|
||||
h('test it', osc_code=9)
|
||||
self.ae(di.notifications, [n(title='test it')])
|
||||
activate()
|
||||
assert_events()
|
||||
reset()
|
||||
|
||||
h('d=0:u=2:i=x;title')
|
||||
h('d=1:i=x:p=body;body')
|
||||
self.ae(notifications, [n(client_id='x', body='body', urgency=Urgency.Critical)])
|
||||
activate()
|
||||
assert_events('x')
|
||||
reset()
|
||||
|
||||
h('i=x:p=body:a=-focus;body')
|
||||
self.ae(notifications, [n(client_id='x', title='body')])
|
||||
activate()
|
||||
assert_events('x', focus=False)
|
||||
reset()
|
||||
|
||||
h('i=x:e=1;' + standard_b64encode(b'title').decode('ascii'))
|
||||
self.ae(notifications, [n(client_id='x', )])
|
||||
activate()
|
||||
assert_events('x')
|
||||
reset()
|
||||
|
||||
h('e=1;' + standard_b64encode(b'title').decode('ascii'))
|
||||
self.ae(notifications, [n()])
|
||||
activate()
|
||||
assert_events()
|
||||
reset()
|
||||
|
||||
h('d=0:i=x:a=-report;title')
|
||||
h('d=1:i=x:a=report;body')
|
||||
self.ae(notifications, [n(client_id='x', title='titlebody')])
|
||||
activate()
|
||||
assert_events('x', report=True)
|
||||
reset()
|
||||
|
||||
h('d=0:i=y;title')
|
||||
h('d=1:i=y:p=xxx;title')
|
||||
self.ae(notifications, [n(client_id='y')])
|
||||
reset()
|
||||
|
||||
# test closing interactions with reporting and activation
|
||||
h('i=c;title')
|
||||
self.ae(notifications, [n(client_id='c')])
|
||||
close()
|
||||
assert_events('c', focus=False, close=True)
|
||||
reset()
|
||||
h('i=c;title')
|
||||
self.ae(notifications, [n(client_id='c')])
|
||||
h('i=c:p=close')
|
||||
self.ae(notifications, [n(client_id='c')])
|
||||
assert_events('c', focus=False, close=True)
|
||||
reset()
|
||||
h('i=c;title')
|
||||
h('i=c:p=close;notify')
|
||||
assert_events('c', focus=False, close=True, close_response=True)
|
||||
reset()
|
||||
|
||||
h(';title')
|
||||
self.ae(notifications, [n()])
|
||||
activate()
|
||||
assert_events()
|
||||
reset()
|
||||
|
||||
# Test querying
|
||||
h('i=xyz:p=?')
|
||||
self.assertFalse(notifications)
|
||||
qr = 'a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close'
|
||||
self.ae(query_responses, [f'99;i=xyz:p=?;{qr}'])
|
||||
reset()
|
||||
h('p=?')
|
||||
self.assertFalse(notifications)
|
||||
self.ae(query_responses, [f'99;i=0:p=?;{qr}'])
|
||||
|
||||
|
||||
class TestNotifications(BaseTest):
|
||||
|
||||
def test_desktop_notify(self):
|
||||
do_test(self)
|
||||
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
from base64 import standard_b64encode
|
||||
from binascii import hexlify
|
||||
from functools import partial
|
||||
|
||||
|
|
@ -15,7 +14,6 @@
|
|||
test_find_either_of_two_bytes,
|
||||
test_utf8_decode_to_sentinel,
|
||||
)
|
||||
from kitty.notify import NotificationCommand, QueryResponse, Urgency, handle_notification_cmd, notification_activated, reset_registry
|
||||
|
||||
from . import BaseTest, parse_bytes
|
||||
|
||||
|
|
@ -521,92 +519,6 @@ def test_osc_codes(self):
|
|||
c.clear()
|
||||
pb('\033]52;p;xyz\x07', ('clipboard_control', 52, 'p;xyz'))
|
||||
|
||||
def test_desktop_notify(self):
|
||||
reset_registry()
|
||||
notifications = []
|
||||
activations = []
|
||||
query_responses = []
|
||||
prev_cmd = NotificationCommand()
|
||||
|
||||
def reset():
|
||||
nonlocal prev_cmd
|
||||
reset_registry()
|
||||
del notifications[:]
|
||||
del activations[:]
|
||||
del query_responses[:]
|
||||
prev_cmd = NotificationCommand()
|
||||
|
||||
def notify(title, body, identifier, urgency=Urgency.Normal):
|
||||
notifications.append((title, body, identifier, urgency))
|
||||
|
||||
def h(raw_data, osc_code=99, window_id=1):
|
||||
nonlocal prev_cmd
|
||||
try:
|
||||
x = handle_notification_cmd(osc_code, raw_data, window_id, prev_cmd, notify, log_warnings=False)
|
||||
if x is not None and osc_code == 99:
|
||||
prev_cmd = x
|
||||
except QueryResponse as err:
|
||||
query_responses.append(err.response_string)
|
||||
|
||||
def activated(identifier, window_id, focus, report):
|
||||
activations.append((identifier, window_id, focus, report))
|
||||
|
||||
h('test it', osc_code=9)
|
||||
self.ae(notifications, [('test it', '', 'i0', Urgency.Normal)])
|
||||
notification_activated(notifications[-1][-2], activated)
|
||||
self.ae(activations, [('0', 1, True, False)])
|
||||
reset()
|
||||
|
||||
h('d=0:u=2:i=x;title')
|
||||
h('d=1:i=x:p=body;body')
|
||||
self.ae(notifications, [('title', 'body', 'i0', Urgency.Critical)])
|
||||
notification_activated(notifications[-1][-2], activated)
|
||||
self.ae(activations, [('x', 1, True, False)])
|
||||
reset()
|
||||
|
||||
h('i=x:p=body:a=-focus;body')
|
||||
self.ae(notifications, [('body', '', 'i0', Urgency.Normal)])
|
||||
notification_activated(notifications[-1][-2], activated)
|
||||
self.ae(activations, [])
|
||||
reset()
|
||||
|
||||
h('i=x:e=1;' + standard_b64encode(b'title').decode('ascii'))
|
||||
self.ae(notifications, [('title', '', 'i0', Urgency.Normal)])
|
||||
notification_activated(notifications[-1][-2], activated)
|
||||
self.ae(activations, [('x', 1, True, False)])
|
||||
reset()
|
||||
|
||||
h('e=1;' + standard_b64encode(b'title').decode('ascii'))
|
||||
self.ae(notifications, [('title', '', 'i0', Urgency.Normal)])
|
||||
notification_activated(notifications[-1][-2], activated)
|
||||
self.ae(activations, [('0', 1, True, False)])
|
||||
reset()
|
||||
|
||||
h('d=0:i=x:a=-report;title')
|
||||
h('d=1:i=x:a=report;body')
|
||||
self.ae(notifications, [('titlebody', '', 'i0', Urgency.Normal)])
|
||||
notification_activated(notifications[-1][-2], activated)
|
||||
self.ae(activations, [('x', 1, True, True)])
|
||||
reset()
|
||||
|
||||
h('d=0:i=y;title')
|
||||
h('d=1:i=y:p=xxx;title')
|
||||
self.ae(notifications, [('title', '', 'i0', Urgency.Normal)])
|
||||
reset()
|
||||
|
||||
h(';title')
|
||||
self.ae(notifications, [('title', '', 'i0', Urgency.Normal)])
|
||||
notification_activated(notifications[-1][-2], activated)
|
||||
self.ae(activations, [('0', 1, True, False)])
|
||||
reset()
|
||||
h('i=xyz:p=?')
|
||||
self.assertFalse(notifications)
|
||||
self.ae(query_responses, ['99;i=xyz:p=?;a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close'])
|
||||
reset()
|
||||
h('p=?')
|
||||
self.assertFalse(notifications)
|
||||
self.ae(query_responses, ['99;i=0:p=?;a=focus,report:o=always,unfocused,invisible:u=0,1,2:p=title,body,?,close'])
|
||||
|
||||
def test_dcs_codes(self):
|
||||
s = self.create_screen()
|
||||
c = s.callbacks
|
||||
|
|
|
|||
Loading…
Reference in a new issue