diff --git a/docs/changelog.rst b/docs/changelog.rst index 2dcf1a9c1..9a5ba128a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,6 +16,9 @@ To update |kitty|, :doc:`follow the instructions `. so people using kitty from homebrew/source are out of luck. Complain to Apple. +- Add a new extensible escape code to allow terminal programs to trigger + desktop notifications. See :ref:`desktop_notifications` (:iss:`1474`) + 0.18.3 [2020-08-11] ------------------- diff --git a/docs/protocol-extensions.rst b/docs/protocol-extensions.rst index 425cd3dce..7e51b9019 100644 --- a/docs/protocol-extensions.rst +++ b/docs/protocol-extensions.rst @@ -228,3 +228,126 @@ no ill-effects in other terminal emulators. In case you're using software that can't be easily adapted to this protocol extension, it can be disabled by specifying ``no-append`` to the :opt:`clipboard_control` setting. + + +.. _desktop_notifications: + + +Desktop notifications +--------------------------------- + +|kitty| implements an extensible escape code (OSC 99) to show desktop +notifications. It is easy to use from shell scripts and fully extensible to +show title and body. Clicking on the notification can optionally focus the +window it came from, and/or send an escape code back to the application running +in that window. + +The design of the escape code is partially based on the discussion in +the defunct +`terminal-wg `_ + +The escape code has the form:: + + 99 ; metadata ; payload + +Here ```` is :code:`]` and ```` is :code:``. The +metadata is a section of colon separated :code:`key=value` pairs. Every key +must be a single character from the set :code:`[a-zA-Z]` and every value must +be a character from the set :code:`a-zA-Z0-9-_/\+.,(){}[]*&^%$#@!`~`. The +payload must be interpreted based on the metadata section. The two semi-colons +*must* always be present even when no metadata is present. + +Before going into details, lets see how one can display a simple, single line +notification from a shell script:: + + printf '\x1b]99;;Hello world\x1b\\' + +To show a message with a title and a body:: + + printf '\x1b]99;i=1:d=0;Hello world\x1b\\' + printf '\x1b]99;i=1:d=1:p=body;This is cool\x1b\\' + +The most important key in the metadata is the ``p`` key, it controls how the +payload is interpreted. A value of ``title`` means the payload is setting the +title for the notification. A value of ``body`` means it is setting the body, +and so on, see the table below for full details. + +The design of the escape code is fundamentally chunked, this is because +different terminal emulators have different limits on how large a single escape +code can be. Chunking is accomplished by the ``i`` and ``d`` keys. The ``i`` +key is the *notification id* which can be any string containing the characters +``[a-zA-Z0-9_-+.]``. The ``d`` key stands for *done* and +can only take the values ``0`` and ``1``. A value of ``0`` means the +notification is not yet done and the terminal emulator should hold off +displaying it. A value of ``1`` means the notification is done, and should be +displayed. You can specify the title or body multiple times and the terminal +emulator will concatenate them, thereby allowing arbitrarily long text +(terminal emulators are free to impose a sensible limit to avoid +Denial-of-Service attacks). + +Both the ``title`` and ``body`` payloads must be either UTF-8 encoded plain +text with no embedded escape codes, or UTF-8 text that is base64 encoded, in +which case there must be an ``e=1`` key in the metadata to indicate the payload +is base64 encoded. + +When the user clicks the notification, a couple of things can happen, the +terminal emulator can focus the window from which the notification came, and/or +it can send back an escape code to the application indicating the notification +was activated. This is controlled by the ``a`` key which takes a comma +separated set of values, ``report`` and ``focus``. The value ``focus`` means +focus the window from which the notification was issued and is the default. +``report`` means send an escape code back to the application. The format of the +escape code is:: + + 99 ; i=identifier ; + +The value of ``identifier`` comes from the ``i`` key in the escape code sent by +the application. If the application sends no identifier, then the terminal +*must* use ``i=0``. Actions can be preceded by a negative sign to turn them +off, so for example if you do not want any action, turn off the default +``focus`` action with:: + + a=-focus + +Complete specification of all the metadata keys is in the table below. If a +terminal emulator encounters a key in the metadata it does not understand, +the key *must* be ignored, to allow for future extensibility of this escape +code. Similarly if values for known keys are unknown, the terminal emulator +*should* either ignore the entire escape code or perform a best guess effort +to display it based on what it does understand. + +.. note:: + It is possible to extend this escape code to allow specifying an icon for + the notification, however, given that some platforms, such as macOS, dont + allow displaying custom icons on a notification, at all, it was decided to + leave it out of the spec for the time being. + + Similarly, features such a scheduled notifications could be added in future + revisions. + + +======= ==================== ========= ================= +Key Value Default Description +======= ==================== ========= ================= +``a`` Comma separated list ``focus`` What action to perform when the + of ``report``, notification is clicked + ``focus``, with + optional leading + ``-`` + +``d`` ``0`` or ``1`` ``1`` Indicates if the notification is + complete or not. + +``e`` ``0`` or ``1`` ``0`` If set to ``1`` means the payload is base64 encoded UTF-8, + otherwise it is plain UTF-8 text with no C0 control codes in it + +``i`` ``[a-zA-Z0-9-_+.]`` ``0`` Identifier for the notification + +``p`` One of ``title`` or ``title`` Whether the payload is the notification title or body. If a + ``body``. notification has no title, the body will be used as title. +======= ==================== ========= ================= + + +.. note:: + |kitty| also supports the legacy OSC 9 protocol developed by iTerm2 for + desktop notifications. diff --git a/kitty/boss.py b/kitty/boss.py index f47e942c2..c8291dfa8 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -39,6 +39,7 @@ from .fast_data_types import ( ) from .keys import get_shortcut, shortcut_matches from .layout.base import set_layout_options +from .notify import notification_activated from .options_stub import Options from .os_window_size import initial_window_size_func from .rgb import Color, color_from_int @@ -63,14 +64,6 @@ class OSWindowDict(TypedDict): tabs: List[TabDict] -def notification_activated(identifier: str) -> 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') - - def listen_on(spec: str) -> int: import socket family, address, socket_path = parse_address_spec(spec) @@ -1545,3 +1538,12 @@ class Boss: now = monotonic() ident = f'test-notify-{now}' notify(f'Test {now}', f'At: {now}', identifier=ident) + + 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) diff --git a/kitty/notify.py b/kitty/notify.py index 704def436..fb23c143e 100644 --- a/kitty/notify.py +++ b/kitty/notify.py @@ -2,9 +2,14 @@ # vim:fileencoding=utf-8 # License: GPLv3 Copyright: 2019, Kovid Goyal -from typing import Dict, Optional +from base64 import standard_b64decode +from collections import OrderedDict +from itertools import count +from typing import Dict, Optional, Callable from .constants import is_macos, logo_png_file +from .fast_data_types import get_boss +from .utils import log_error if is_macos: from .fast_data_types import cocoa_send_notification @@ -35,7 +40,6 @@ else: rmap = {v: k for k, v in identifier_map.items()} identifier = rmap.get(notification_id) if identifier is not None: - from .boss import notification_activated notification_activated(identifier) def notify( @@ -52,3 +56,147 @@ else: alloc_id = dbus_send_notification(application, icf, title, body, 'Click to see changes', timeout) if alloc_id and identifier is not None: alloc_map[alloc_id] = identifier + + +class NotificationCommand: + + done: bool = True + identifier: str = '0' + title: str = '' + body: str = '' + actions: str = '' + + +def parse_osc_9(raw: str) -> NotificationCommand: + ans = NotificationCommand() + ans.title = raw + return ans + + +def parse_osc_99(raw: str) -> NotificationCommand: + cmd = NotificationCommand() + metadata, payload = raw.partition(';')[::2] + payload_is_encoded = False + payload_type = 'title' + if metadata: + for part in metadata.split(':'): + try: + k, v = part.split('=', 1) + except Exception: + log_error('Malformed OSC 99: metadata is not key=value pairs') + return cmd + if k == 'p': + payload_type = v + elif k == 'i': + cmd.identifier = v + elif k == 'e': + payload_is_encoded = v == '1' + elif k == 'd': + cmd.done = v != '0' + elif k == 'a': + cmd.actions += ',' + v + if payload_type not in ('body', 'title'): + log_error(f'Malformed OSC 99: unknown payload type: {payload_type}') + return NotificationCommand() + if payload_is_encoded: + try: + payload = standard_b64decode(payload).decode('utf-8') + except Exception: + log_error('Malformed OSC 99: payload is not base64 encoded UTF-8 text') + return NotificationCommand() + if payload_type == 'title': + cmd.title = payload + else: + cmd.body = 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 = limit_size(prev.actions + ',' + cmd.actions) + cmd.title = limit_size(prev.title + cmd.title) + cmd.body = limit_size(prev.body + cmd.body) + return cmd + + +identifier_registry: "OrderedDict[str, RegisteredNotification]" = OrderedDict() +id_counter = count() + + +class RegisteredNotification: + identifier: str + window_id: int + focus: bool = True + report: bool = False + + def __init__(self, cmd: NotificationCommand, window_id: int): + self.window_id = window_id + for x in cmd.actions.strip(',').split(','): + val = not x.startswith('-') + x = x.lstrip('+-') + if x == 'focus': + self.focus = val + elif x == 'report': + self.report = val + 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] = 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: Callable = notify) -> None: + title = cmd.title or cmd.body + body = cmd.body if cmd.title else '' + if title: + identifier = 'i' + str(next(id_counter)) + notify(title, body, identifier=identifier) + register_identifier(identifier, cmd, window_id) + + +def handle_notification_cmd( + osc_code: int, + raw_data: str, + window_id: int, + prev_cmd: NotificationCommand, + notify_implementation: Callable = notify +) -> Optional[NotificationCommand]: + if osc_code == 99: + cmd = merge_osc_99(prev_cmd, parse_osc_99(raw_data)) + 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) diff --git a/kitty/parser.c b/kitty/parser.c index c64908f1d..94cc65f4f 100644 --- a/kitty/parser.c +++ b/kitty/parser.c @@ -301,10 +301,11 @@ handle_esc_mode_char(Screen *screen, uint32_t ch, PyObject DUMP_UNUSED *dump_cal } // }}} // OSC mode {{{ + static inline void dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { +#define DISPATCH_OSC_WITH_CODE(name) REPORT_OSC2(name, code, string); name(screen, code, string); #define DISPATCH_OSC(name) REPORT_OSC(name, string); name(screen, string); -#define SET_COLOR(name) REPORT_OSC2(name, code, string); name(screen, code, string); const unsigned int limit = screen->parser_buf_pos; unsigned int code=0, i; for (i = 0; i < MIN(limit, 5u); i++) { @@ -329,7 +330,11 @@ dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { break; case 4: case 104: - SET_COLOR(set_color_table_color); + DISPATCH_OSC_WITH_CODE(set_color_table_color); + break; + case 9: + case 99: + DISPATCH_OSC_WITH_CODE(desktop_notify) break; case 10: case 11: @@ -341,7 +346,7 @@ dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { case 112: case 117: case 119: - SET_COLOR(set_dynamic_color); + DISPATCH_OSC_WITH_CODE(set_dynamic_color); break; case 52: DISPATCH_OSC(clipboard_control); @@ -361,7 +366,7 @@ dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { Py_CLEAR(string); } #undef DISPATCH_OSC -#undef SET_COLOR +#undef DISPATCH_OSC_WITH_CODE } // }}} diff --git a/kitty/screen.c b/kitty/screen.c index d12a24503..9bc5c6eb1 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -1495,6 +1495,11 @@ set_title(Screen *self, PyObject *title) { CALLBACK("title_changed", "O", title); } +void +desktop_notify(Screen *self, unsigned int osc_code, PyObject *data) { + CALLBACK("desktop_notify", "IO", osc_code, data); +} + void set_icon(Screen *self, PyObject *icon) { CALLBACK("icon_changed", "O", icon); diff --git a/kitty/screen.h b/kitty/screen.h index 32d3746d2..2f26d88d4 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -178,6 +178,7 @@ void screen_handle_print(Screen *, PyObject *cmd); void screen_designate_charset(Screen *, uint32_t which, uint32_t as); void screen_use_latin1(Screen *, bool); void set_title(Screen *self, PyObject*); +void desktop_notify(Screen *self, unsigned int, PyObject*); void set_icon(Screen *self, PyObject*); void set_dynamic_color(Screen *self, unsigned int code, PyObject*); void clipboard_control(Screen *self, PyObject*); diff --git a/kitty/window.py b/kitty/window.py index da3faec8d..af24898f3 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -30,6 +30,7 @@ from .fast_data_types import ( viewport_for_window ) from .keys import defines, extended_key_event, keyboard_mode_name +from .notify import NotificationCommand, handle_notification_cmd from .options_stub import Options from .rgb import to_color from .terminfo import get_capabilities @@ -218,6 +219,7 @@ class Window: watchers: Optional[Watchers] = None ): self.watchers = watchers or Watchers() + self.prev_osc99_cmd = NotificationCommand() self.action_on_close: Optional[Callable] = None self.action_on_removal: Optional[Callable] = None self.current_marker_spec: Optional[Tuple[str, Union[str, Tuple[Tuple[int, str], ...]]]] = None @@ -434,6 +436,11 @@ class Window: self.override_title = title or None self.title_updated() + def desktop_notify(self, osc_code: int, raw_data: str) -> None: + 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 + # screen callbacks {{{ def use_utf8(self, on: bool) -> None: get_boss().child_monitor.set_iutf8_winid(self.id, on) @@ -511,6 +518,9 @@ class Window: b |= b << 8 self.screen.send_escape_code_to_child(OSC, '{};rgb:{:04x}/{:04x}/{:04x}'.format(code, r, g, b)) + def report_notification_activated(self, identifier: str) -> None: + self.screen.send_escape_code_to_child(OSC, f'99;i={identifier};') + def set_dynamic_color(self, code: int, value: Union[str, bytes]) -> None: if isinstance(value, bytes): value = value.decode('utf-8') diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index 96438a131..7189fccec 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -37,10 +37,14 @@ class Callbacks: def use_utf8(self, on): self.iutf8 = on + def desktop_notify(self, osc_code: int, raw_data: str) -> None: + self.notifications.append((osc_code, raw_data)) + def clear(self): self.wtcbuf = b'' self.iconbuf = self.titlebuf = self.colorbuf = self.ctbuf = '' self.iutf8 = True + self.notifications = [] def filled_line_buf(ynum=5, xnum=5, cursor=Cursor()): diff --git a/kitty_tests/parser.py b/kitty_tests/parser.py index 17df3cf3c..8e54a2d78 100644 --- a/kitty_tests/parser.py +++ b/kitty_tests/parser.py @@ -3,10 +3,15 @@ # License: GPL v3 Copyright: 2016, Kovid Goyal import time +from base64 import standard_b64encode from binascii import hexlify from functools import partial from kitty.fast_data_types import CURSOR_BLOCK, parse_bytes, parse_bytes_dump +from kitty.notify import ( + NotificationCommand, handle_notification_cmd, notification_activated, + reset_registry +) from . import BaseTest @@ -195,6 +200,7 @@ class TestParser(BaseTest): pb('\t\033[b', ('screen_tab',), ('screen_repeat_character', 1)) self.ae(str(s.line(0)), '\t') s.reset() + b']]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]' def test_osc_codes(self): s = self.create_screen() @@ -216,6 +222,74 @@ class TestParser(BaseTest): self.ae(c.titlebuf, '') pb('\033]110\x07', ('set_dynamic_color', 110, '')) self.ae(c.colorbuf, '') + c.clear() + pb('\033]9;\x07', ('desktop_notify', 9, '')) + pb('\033]9;test it\x07', ('desktop_notify', 9, 'test it')) + pb('\033]99;moo=foo;test it\x07', ('desktop_notify', 99, 'moo=foo;test it')) + self.ae(c.notifications, [(9, ''), (9, 'test it'), (99, 'moo=foo;test it')]) + + def test_desktop_notify(self): + reset_registry() + notifications = [] + activations = [] + prev_cmd = NotificationCommand() + + def reset(): + nonlocal prev_cmd + reset_registry() + del notifications[:] + del activations[:] + prev_cmd = NotificationCommand() + + def notify(title, body, identifier): + notifications.append((title, body, identifier)) + + def h(raw_data, osc_code=99, window_id=1): + nonlocal prev_cmd + x = handle_notification_cmd(osc_code, raw_data, window_id, prev_cmd, notify) + if x is not None and osc_code == 99: + prev_cmd = x + + 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')]) + notification_activated(notifications[-1][-1], activated) + self.ae(activations, [('0', 1, True, False)]) + reset() + + h('d=0:i=x;title') + h('d=1:i=x:p=body;body') + self.ae(notifications, [('title', 'body', 'i0')]) + notification_activated(notifications[-1][-1], activated) + self.ae(activations, [('x', 1, True, False)]) + reset() + + h('i=x:p=body:a=-focus;body') + self.ae(notifications, [('body', '', 'i0')]) + notification_activated(notifications[-1][-1], activated) + self.ae(activations, []) + reset() + + h('i=x:e=1;' + standard_b64encode(b'title').decode('ascii')) + self.ae(notifications, [('title', '', 'i0')]) + notification_activated(notifications[-1][-1], activated) + self.ae(activations, [('x', 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')]) + notification_activated(notifications[-1][-1], activated) + self.ae(activations, [('x', 1, True, True)]) + reset() + + h(';title') + self.ae(notifications, [('title', '', 'i0')]) + notification_activated(notifications[-1][-1], activated) + self.ae(activations, [('0', 1, True, False)]) + reset() def test_dcs_codes(self): s = self.create_screen()