Implement handling of icon names on Linux

This commit is contained in:
Kovid Goyal 2024-07-29 14:39:20 +05:30
parent 6e35289f8e
commit ad48ecad40
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
7 changed files with 237 additions and 16 deletions

View file

@ -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
<https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#file-naming>`__
(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::

View file

@ -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)

View file

@ -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 @@
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 @@
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.

View file

@ -188,6 +188,20 @@ def wakeup_io_loop() -> None:
# 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 ''

View file

@ -11,7 +11,7 @@
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 @@ def parse_metadata(self, metadata: str, prev: 'NotificationCommand') -> Tuple[Pa
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 @@ def merge_metadata(self, prev: 'NotificationCommand') -> None:
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 @@ def dispatch_event_from_desktop(self, event_type: str, dbus_notification_id: int
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

134
kitty/xdg.py Normal file
View file

@ -0,0 +1,134 @@
#!/usr/bin/env python
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
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

View file

@ -13,11 +13,11 @@
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 @@ def notify(self, cmd, existing_desktop_notification_id) -> int:
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 e(x):
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 send_with_icon(data='', n='', g=''):
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')