Implement handling of icon names on Linux
This commit is contained in:
parent
6e35289f8e
commit
ad48ecad40
7 changed files with 237 additions and 16 deletions
|
|
@ -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::
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
|
|
|
|||
|
|
@ -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
134
kitty/xdg.py
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue