diff --git a/docs/desktop-notifications.rst b/docs/desktop-notifications.rst index 8b382c4af..1315d6a35 100644 --- a/docs/desktop-notifications.rst +++ b/docs/desktop-notifications.rst @@ -70,6 +70,13 @@ notifications they do not want. Both keys must have :rfc:`base64 <4648>` encoded UTF-8 text as their values. Terminals can then present UI to users to allow them to filter out notifications from applications they do not want. +.. note:: + The application name should generally be set to the filename of the + applications `desktop file + `__ + (without the ``.desktop`` part) or the bundle identifier for a macOS + application. While not strictly necessary, this allows the terminal + emulator to deduce an icon for the notification when one is not specified. .. note:: diff --git a/kittens/notify/main.go b/kittens/notify/main.go index 17b1a3338..1a9ba1776 100644 --- a/kittens/notify/main.go +++ b/kittens/notify/main.go @@ -26,6 +26,11 @@ func b64encode(x string) string { return base64.RawStdEncoding.EncodeToString(utils.UnsafeStringToBytes(x)) } +func check_id_valid(x string) bool { + pat := utils.MustCompile(`[^a-zA-Z0-9_+.-]`) + return pat.ReplaceAllString(x, "") == x +} + func create_metadata(opts *Options, wait_till_closed bool, expire_time time.Duration) string { ans := []string{} if opts.AppName != "" { @@ -46,6 +51,12 @@ func create_metadata(opts *Options, wait_till_closed bool, expire_time time.Dura if wait_till_closed { ans = append(ans, "c=1:a=report") } + for _, x := range opts.Icon { + ans = append(ans, "n="+b64encode(x)) + } + if opts.IconCacheId != "" { + ans = append(ans, "g="+opts.IconCacheId) + } m := strings.Join(ans, ":") if m != "" { m = ":" + m @@ -221,6 +232,15 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { return 1, fmt.Errorf("Failed to generate a random identifier with error: %w", err) } } + bad_ident := func(which string) error { + return fmt.Errorf("Invalid identifier: %s must be only English letters, numbers, hyphens and underscores.", which) + } + if !check_id_valid(ident) { + return 1, bad_ident(ident) + } + if !check_id_valid(opts.IconCacheId) { + return 1, bad_ident(opts.IconCacheId) + } var expire_time time.Duration if expire_time, err = parse_duration(opts.ExpireTime); err != nil { return 1, fmt.Errorf("Invalid expire time: %s with error: %w", opts.ExpireTime, err) diff --git a/kittens/notify/main.py b/kittens/notify/main.py index a469b81ae..b0e4c1497 100644 --- a/kittens/notify/main.py +++ b/kittens/notify/main.py @@ -3,7 +3,25 @@ import sys -OPTIONS = r''' + +def OPTIONS() -> str: + from kitty.constants import standard_icon_names + return f''' +--icon -i +type=list +The name of the icon to use for the notification. An icon with this name +will be searched for on the computer running the terminal emulator. Can +be specified multiple times, the first name that is found will be used. +Standard names: {', '.join(sorted(standard_icon_names))} + + +--icon-path -I +Path to an image file in PNG/JPEG/WEBP/GIF formats to use as the icon. If both +name and path are specified then first the name will be looked for and if not found +then the path will be used. Other image formats are supported if ImageMagick is +installed on the system. + + --app-name -a default=kitten-notify The application name for the notification. @@ -31,7 +49,7 @@ The notification type. Can be any string, it is used by users to create filter r for notifications, so choose something descriptive of the notifications, purpose. ---identifier -i +--identifier The identifier of this notification. If a notification with the same identifier is already displayed, it is replaced/updated. @@ -54,7 +72,15 @@ type=bool-set Only print the escape code to STDOUT. Useful if using this kitten as part of a larger application. If this is specified, the --wait-till-closed option will be used for escape code generation, but no actual waiting will be done. -'''.format + + +--icon-cache-id -g +Identifier to use when caching icons in the terminal emulator. Using an identifier means +that icon data needs to be transmitted only once using --icon-path. Subsequent invocations +will use the cached icon data, at least until the terminal instance is restarted. This is useful +if this kitten is being used inside a larger application, with --only-print-escape-code. +''' + help_text = '''\ Send notifications to the user that are displayed to them via the desktop environment's notifications service. Works over SSH as well. diff --git a/kitty/constants.py b/kitty/constants.py index 616d1937d..e317e179c 100644 --- a/kitty/constants.py +++ b/kitty/constants.py @@ -188,6 +188,20 @@ except KeyError: # https://github.com/ansible/ansible/issues/11536#issuecomment-153030743 ssh_control_master_template = 'kssh-{kitty_pid}-{ssh_placeholder}' +# See https://specifications.freedesktop.org/icon-naming-spec/latest/ar01s04.html +standard_icon_names = { + 'error': 'dialog-error', + 'warning': 'dialog-warning', + 'warn': 'dialog-warning', + 'info': 'dialog-information', + 'question': 'dialog-question', + + 'help': 'system-help', + 'file-manager': 'system-file-manager', + 'system-monitor': 'utilities-system-monitor', + 'text-editor': 'utilities-text-editor', +} + def glfw_path(module: str) -> str: prefix = 'kitty.' if getattr(sys, 'frozen', False) else '' diff --git a/kitty/notifications.py b/kitty/notifications.py index 7c4bff7e9..3427eb531 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -11,7 +11,7 @@ from itertools import count from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Sequence, Set, Tuple, Union from weakref import ReferenceType, ref -from .constants import cache_dir, config_dir, is_macos, logo_png_file +from .constants import cache_dir, config_dir, is_macos, logo_png_file, standard_icon_names 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 .typing import WindowType @@ -207,7 +207,7 @@ class NotificationCommand: only_when: OnlyWhen = OnlyWhen.unset urgency: Optional[Urgency] = None icon_data_key: str = '' - icon_name: str = '' + icon_names: Tuple[str, ...] = () application_name: str = '' notification_type: str = '' timeout: int = -2 @@ -296,7 +296,10 @@ class NotificationCommand: elif k == 'g': self.icon_data_key = sanitize_id(v) elif k == 'n': - self.icon_name = v + try: + self.icon_names += (base64_decode(v).decode('utf-8', 'replace'),) + except Exception: + self.log('Ignoring invalid icon name in notification: {v!r}') elif k == 'f': try: self.application_name = base64_decode(v).decode('utf-8', 'replace') @@ -328,8 +331,8 @@ class NotificationCommand: self.close_response_requested = prev.close_response_requested if not self.icon_data_key: self.icon_data_key = prev.icon_data_key - if not self.icon_name: - self.icon_name = prev.icon_name + if prev.icon_names: + self.icon_names += prev.icon_names if not self.application_name: self.application_name = prev.application_name if not self.notification_type: @@ -574,7 +577,23 @@ class FreeDesktopIntegration(DesktopIntegration): def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Optional[int]) -> int: from .fast_data_types import dbus_send_notification - app_icon = nc.icon_name or nc.icon_path or get_custom_window_icon()[1] or logo_png_file + from .xdg import icon_exists, icon_for_appname + app_icon = '' + if nc.icon_names: + for name in nc.icon_names: + if sn := standard_icon_names.get(name): + app_icon = sn + break + if icon_exists(name): + app_icon = name + break + if not app_icon: + app_icon = nc.icon_path or nc.icon_names[0] + else: + app_icon = nc.icon_path or icon_for_appname(nc.application_name) + if not app_icon: + app_icon = get_custom_window_icon()[1] or logo_png_file + body = nc.body.replace('<', '<\u200c').replace('&', '&\u200c') # prevent HTML markup from being recognized assert nc.urgency is not None replaces_dbus_id = 0 diff --git a/kitty/xdg.py b/kitty/xdg.py new file mode 100644 index 000000000..f640661e1 --- /dev/null +++ b/kitty/xdg.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2024, Kovid Goyal + +import os +import re +from contextlib import suppress + +from kitty.types import run_once + + +@run_once +def xdg_data_dirs() -> tuple[str, ...]: + return tuple(os.environ.get('XDG_DATA_DIRS', '/usr/local/share/:/usr/share/').split(os.pathsep)) + + +@run_once +def icon_dirs() -> list[str]: + ans = [] + def a(x: str) -> None: + if os.path.isdir(x): + ans.append(x) + + a(os.path.expanduser('~/.icons')) + for x in xdg_data_dirs(): + a(os.path.join(x, 'icons')) + return ans + + +class XDGIconCache: + + def __init__(self) -> None: + self.existing_icon_names: set[str] = set() + self.scanned = False + self.themes_to_search: set[str] = set() + + def find_inherited_themes(self, basedir: str) -> bool: + with suppress(OSError), open(os.path.join(basedir, 'index.theme')) as f: + raw = f.read() + if m := re.search(r'^Inherits\s*=\s*(.+?)$', raw, re.MULTILINE): + for x in m.group(1).split(','): + self.themes_to_search.add(x.strip()) + return True + return False + + def scan(self) -> None: + self.scanned = True + for icdir in icon_dirs(): + if self.find_inherited_themes(os.path.join(icdir, 'default')): + break + self.themes_to_search.add('hicolor') + while True: + before = len(self.themes_to_search) + for icdir in icon_dirs(): + for theme in tuple(self.themes_to_search): + self.find_inherited_themes(os.path.join(icdir, theme)) + if len(self.themes_to_search) == before: + break + for icdir in icon_dirs(): + for theme in self.themes_to_search: + self.scan_theme_dir(os.path.join(icdir, theme)) + self.scan_theme_dir('/usr/share/pixmaps') + + def scan_theme_dir(self, base: str) -> None: + with suppress(OSError): + for (dirpath, dirnames, filenames) in os.walk(base): + for q in filenames: + icon_name, sep, ext = q.lower().rpartition('.') + if sep == '.' and ext in ('svg', 'png', 'xpm'): + self.existing_icon_names.add(icon_name) + + def icon_exists(self, name: str) -> bool: + if not self.scanned: + self.scan() + return name.lower() in self.existing_icon_names + + +xdg_icon_cache = XDGIconCache() +icon_exists = xdg_icon_cache.icon_exists + + +class AppIconCache: + def __init__(self) -> None: + self.scanned = False + self.lcase_app_name_to_path: dict[str, str] = {} + self.lcase_full_name_to_path: dict[str, str] = {} + self.icon_name_cache: dict[str, str] = {} + + def scan(self) -> None: + self.scanned = True + for d in xdg_data_dirs(): + d = os.path.join(d, 'applications') + with suppress(OSError): + for (dirpath, dirnames, filenames) in os.walk(d): + for fname in filenames: + if fname.endswith('.desktop'): + path = os.path.join(dirpath, fname) + self.process_desktop_file(path, os.path.relpath(path, d)) + + def process_desktop_file(self, path: str, relpath: str) -> None: + # file_id = relpath.replace('/', '-') + bname = os.path.basename(relpath) + parts = bname.split('.')[:-1] + appname = parts[-1] + self.lcase_app_name_to_path[appname.lower()] = path + self.lcase_full_name_to_path['.'.join(parts).lower()] = path + + def icon_for_appname(self, appname: str) -> str: + if not self.scanned: + self.scan() + q = appname.lower() + if not appname or q in ('kitty', 'kitten', 'kitten-notify'): + return '' + path = self.lcase_full_name_to_path.get(q) or self.lcase_app_name_to_path.get(q) + if not path: + return '' + ans = self.icon_name_cache.get(path) + if ans is None: + try: + ans = self.icon_name_cache[path] = self.icon_name_from_desktop_file(path) + except OSError: + ans = self.icon_name_cache[path] = '' + + return ans + + def icon_name_from_desktop_file(self, path: str) -> str: + with open(path) as f: + raw = f.read() + if m := re.search(r'^Icon\s*=\s*(.+?)\s*?$', raw, re.MULTILINE): + return m.group(1) + return '' + + +app_icon_cache = AppIconCache() +icon_for_appname = app_icon_cache.icon_for_appname diff --git a/kitty_tests/notifications.py b/kitty_tests/notifications.py index ee947ae89..cb4c2be96 100644 --- a/kitty_tests/notifications.py +++ b/kitty_tests/notifications.py @@ -13,11 +13,11 @@ from . import BaseTest def n( - title='title', body='', urgency=Urgency.Normal, desktop_notification_id=1, icon_name='', icon_path='', + title='title', body='', urgency=Urgency.Normal, desktop_notification_id=1, icon_names=(), icon_path='', application_name='', notification_type='', timeout=-1, ): 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_names': icon_names, 'icon_path': icon_path, 'application_name': application_name, 'notification_type': notification_type, 'timeout': timeout } @@ -53,7 +53,7 @@ class DesktopIntegration(DesktopIntegration): self.counter += 1 did = self.counter 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, timeout=cmd.timeout) + ans = n(title, body, urgency, did, cmd.icon_names, os.path.basename(cmd.icon_path), cmd.application_name, cmd.notification_type, timeout=cmd.timeout) self.notifications.append(ans) return self.counter @@ -287,15 +287,16 @@ def do_test(self: 'TestNotifications', tdir: str) -> None: def send_with_icon(data='', n='', g=''): m = '' if n: - m += f'n={n}:' + for x in n.split(','): + m += f'n={standard_b64encode(x.encode()).decode()}:' if g: m += f'g={g}:' h(f'i=9:d=0:{m};title') h(f'i=9:p=icon;{data}') dc = nm.icon_data_cache - send_with_icon(n='mycon') - self.ae(di.notifications, [n(icon_name='mycon')]) + send_with_icon(n='mycon,ic2') + self.ae(di.notifications, [n(icon_names=('mycon', 'ic2'))]) reset() send_with_icon(g='gid') self.ae(di.notifications, [n()]) @@ -303,7 +304,7 @@ def do_test(self: 'TestNotifications', tdir: str) -> None: send_with_icon(g='gid', data='1') self.ae(di.notifications, [n(icon_path=dc.hash(b'1'))]) send_with_icon(g='gid', n='moose') - self.ae(di.notifications[-1], n(icon_name='moose', icon_path=dc.hash(b'1'))) + self.ae(di.notifications[-1], n(icon_names=('moose',), icon_path=dc.hash(b'1'))) send_with_icon(g='gid2', data='2') self.ae(di.notifications[-1], n(icon_path=dc.hash(b'2'))) send_with_icon(data='3')