mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-26 10:12:17 +02:00
Implement handling of icon names on Linux
This commit is contained in:
@@ -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
|
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.
|
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::
|
.. note::
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ func b64encode(x string) string {
|
|||||||
return base64.RawStdEncoding.EncodeToString(utils.UnsafeStringToBytes(x))
|
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 {
|
func create_metadata(opts *Options, wait_till_closed bool, expire_time time.Duration) string {
|
||||||
ans := []string{}
|
ans := []string{}
|
||||||
if opts.AppName != "" {
|
if opts.AppName != "" {
|
||||||
@@ -46,6 +51,12 @@ func create_metadata(opts *Options, wait_till_closed bool, expire_time time.Dura
|
|||||||
if wait_till_closed {
|
if wait_till_closed {
|
||||||
ans = append(ans, "c=1:a=report")
|
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, ":")
|
m := strings.Join(ans, ":")
|
||||||
if m != "" {
|
if m != "" {
|
||||||
m = ":" + 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)
|
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
|
var expire_time time.Duration
|
||||||
if expire_time, err = parse_duration(opts.ExpireTime); err != nil {
|
if expire_time, err = parse_duration(opts.ExpireTime); err != nil {
|
||||||
return 1, fmt.Errorf("Invalid expire time: %s with error: %w", opts.ExpireTime, err)
|
return 1, fmt.Errorf("Invalid expire time: %s with error: %w", opts.ExpireTime, err)
|
||||||
|
|||||||
@@ -3,7 +3,25 @@
|
|||||||
|
|
||||||
import sys
|
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
|
--app-name -a
|
||||||
default=kitten-notify
|
default=kitten-notify
|
||||||
The application name for the notification.
|
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.
|
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
|
The identifier of this notification. If a notification with the same identifier
|
||||||
is already displayed, it is replaced/updated.
|
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
|
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
|
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.
|
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 = '''\
|
help_text = '''\
|
||||||
Send notifications to the user that are displayed to them via the
|
Send notifications to the user that are displayed to them via the
|
||||||
desktop environment's notifications service. Works over SSH as well.
|
desktop environment's notifications service. Works over SSH as well.
|
||||||
|
|||||||
@@ -188,6 +188,20 @@ except KeyError:
|
|||||||
# https://github.com/ansible/ansible/issues/11536#issuecomment-153030743
|
# https://github.com/ansible/ansible/issues/11536#issuecomment-153030743
|
||||||
ssh_control_master_template = 'kssh-{kitty_pid}-{ssh_placeholder}'
|
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:
|
def glfw_path(module: str) -> str:
|
||||||
prefix = 'kitty.' if getattr(sys, 'frozen', False) else ''
|
prefix = 'kitty.' if getattr(sys, 'frozen', False) else ''
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from itertools import count
|
|||||||
from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Sequence, Set, Tuple, Union
|
from typing import Any, Callable, Dict, FrozenSet, Iterator, List, NamedTuple, Optional, Sequence, Set, Tuple, Union
|
||||||
from weakref import ReferenceType, ref
|
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 .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 .types import run_once
|
||||||
from .typing import WindowType
|
from .typing import WindowType
|
||||||
@@ -207,7 +207,7 @@ class NotificationCommand:
|
|||||||
only_when: OnlyWhen = OnlyWhen.unset
|
only_when: OnlyWhen = OnlyWhen.unset
|
||||||
urgency: Optional[Urgency] = None
|
urgency: Optional[Urgency] = None
|
||||||
icon_data_key: str = ''
|
icon_data_key: str = ''
|
||||||
icon_name: str = ''
|
icon_names: Tuple[str, ...] = ()
|
||||||
application_name: str = ''
|
application_name: str = ''
|
||||||
notification_type: str = ''
|
notification_type: str = ''
|
||||||
timeout: int = -2
|
timeout: int = -2
|
||||||
@@ -296,7 +296,10 @@ class NotificationCommand:
|
|||||||
elif k == 'g':
|
elif k == 'g':
|
||||||
self.icon_data_key = sanitize_id(v)
|
self.icon_data_key = sanitize_id(v)
|
||||||
elif k == 'n':
|
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':
|
elif k == 'f':
|
||||||
try:
|
try:
|
||||||
self.application_name = base64_decode(v).decode('utf-8', 'replace')
|
self.application_name = base64_decode(v).decode('utf-8', 'replace')
|
||||||
@@ -328,8 +331,8 @@ class NotificationCommand:
|
|||||||
self.close_response_requested = prev.close_response_requested
|
self.close_response_requested = prev.close_response_requested
|
||||||
if not self.icon_data_key:
|
if not self.icon_data_key:
|
||||||
self.icon_data_key = prev.icon_data_key
|
self.icon_data_key = prev.icon_data_key
|
||||||
if not self.icon_name:
|
if prev.icon_names:
|
||||||
self.icon_name = prev.icon_name
|
self.icon_names += prev.icon_names
|
||||||
if not self.application_name:
|
if not self.application_name:
|
||||||
self.application_name = prev.application_name
|
self.application_name = prev.application_name
|
||||||
if not self.notification_type:
|
if not self.notification_type:
|
||||||
@@ -574,7 +577,23 @@ class FreeDesktopIntegration(DesktopIntegration):
|
|||||||
|
|
||||||
def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Optional[int]) -> int:
|
def notify(self, nc: NotificationCommand, existing_desktop_notification_id: Optional[int]) -> int:
|
||||||
from .fast_data_types import dbus_send_notification
|
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
|
body = nc.body.replace('<', '<\u200c').replace('&', '&\u200c') # prevent HTML markup from being recognized
|
||||||
assert nc.urgency is not None
|
assert nc.urgency is not None
|
||||||
replaces_dbus_id = 0
|
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 @@ from . import BaseTest
|
|||||||
|
|
||||||
|
|
||||||
def n(
|
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,
|
application_name='', notification_type='', timeout=-1,
|
||||||
):
|
):
|
||||||
return {
|
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
|
'application_name': application_name, 'notification_type': notification_type, 'timeout': timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ class DesktopIntegration(DesktopIntegration):
|
|||||||
self.counter += 1
|
self.counter += 1
|
||||||
did = self.counter
|
did = self.counter
|
||||||
title, body, urgency = cmd.title, cmd.body, (Urgency.Normal if cmd.urgency is None else cmd.urgency)
|
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)
|
self.notifications.append(ans)
|
||||||
return self.counter
|
return self.counter
|
||||||
|
|
||||||
@@ -287,15 +287,16 @@ def do_test(self: 'TestNotifications', tdir: str) -> None:
|
|||||||
def send_with_icon(data='', n='', g=''):
|
def send_with_icon(data='', n='', g=''):
|
||||||
m = ''
|
m = ''
|
||||||
if n:
|
if n:
|
||||||
m += f'n={n}:'
|
for x in n.split(','):
|
||||||
|
m += f'n={standard_b64encode(x.encode()).decode()}:'
|
||||||
if g:
|
if g:
|
||||||
m += f'g={g}:'
|
m += f'g={g}:'
|
||||||
h(f'i=9:d=0:{m};title')
|
h(f'i=9:d=0:{m};title')
|
||||||
h(f'i=9:p=icon;{data}')
|
h(f'i=9:p=icon;{data}')
|
||||||
|
|
||||||
dc = nm.icon_data_cache
|
dc = nm.icon_data_cache
|
||||||
send_with_icon(n='mycon')
|
send_with_icon(n='mycon,ic2')
|
||||||
self.ae(di.notifications, [n(icon_name='mycon')])
|
self.ae(di.notifications, [n(icon_names=('mycon', 'ic2'))])
|
||||||
reset()
|
reset()
|
||||||
send_with_icon(g='gid')
|
send_with_icon(g='gid')
|
||||||
self.ae(di.notifications, [n()])
|
self.ae(di.notifications, [n()])
|
||||||
@@ -303,7 +304,7 @@ def do_test(self: 'TestNotifications', tdir: str) -> None:
|
|||||||
send_with_icon(g='gid', data='1')
|
send_with_icon(g='gid', data='1')
|
||||||
self.ae(di.notifications, [n(icon_path=dc.hash(b'1'))])
|
self.ae(di.notifications, [n(icon_path=dc.hash(b'1'))])
|
||||||
send_with_icon(g='gid', n='moose')
|
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')
|
send_with_icon(g='gid2', data='2')
|
||||||
self.ae(di.notifications[-1], n(icon_path=dc.hash(b'2')))
|
self.ae(di.notifications[-1], n(icon_path=dc.hash(b'2')))
|
||||||
send_with_icon(data='3')
|
send_with_icon(data='3')
|
||||||
|
|||||||
Reference in New Issue
Block a user