"""Minimal StatusNotifierItem (SNI) HOST — connects to whichever org.kde.StatusNotifierWatcher is already running (the existing eww bars' own systray widget registers one) and renders the currently registered tray items as clickable planet buttons. The SNI spec allows multiple hosts, so registering alongside eww's own host is expected and fine, not a conflict. Rebuilt fresh each time the tray popover box is built (dock.py calls build_into() on every dock reveal, since favorites — and therefore the tray satellite — rebuild then) rather than live-subscribed to Registered/ Unregistered signals — simple and correct for a popover that's only open briefly; if no watcher is running at all, build_into() shows a small placeholder instead of raising. """ from __future__ import annotations from typing import Optional import gi gi.require_version("Gtk", "4.0") gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib, Gtk # noqa: E402 _WATCHER_BUS = "org.kde.StatusNotifierWatcher" _WATCHER_PATH = "/StatusNotifierWatcher" _WATCHER_IFACE = "org.kde.StatusNotifierWatcher" _ITEM_IFACE = "org.kde.StatusNotifierItem" _HOST_NAME = "org.kde.StatusNotifierHost-horizon-dock" class TrayHost: def __init__(self) -> None: self._watcher: Optional[Gio.DBusProxy] = None self._registered_host = False self._connect() def _connect(self) -> None: try: proxy = Gio.DBusProxy.new_for_bus_sync( Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, _WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None, ) # A proxy can be constructed even for a name with no owner yet — only # a real name-owner query tells us whether a watcher is actually up. self._watcher = proxy if proxy.get_name_owner() is not None else None except GLib.Error: self._watcher = None def _ensure_host_registered(self) -> None: if self._registered_host or self._watcher is None: return try: self._watcher.call_sync( "RegisterStatusNotifierHost", GLib.Variant("(s)", (_HOST_NAME,)), Gio.DBusCallFlags.NONE, 500, None, ) except GLib.Error: pass # some watchers don't require/support this explicitly — fine either way self._registered_host = True def _registered_items(self) -> list[str]: if self._watcher is None: return [] value = self._watcher.get_cached_property("RegisteredStatusNotifierItems") if value is None: return [] return list(value.unpack()) @staticmethod def _parse_item(entry: str) -> tuple[str, str]: """Registration strings are either just a bus name (path defaults to /StatusNotifierItem) or "busname/ObjectPath" — implementations disagree, so accept both.""" if "/" in entry: bus, _, path = entry.partition("/") return bus, "/" + path return entry, "/StatusNotifierItem" # -- public: build the popover contents ----------------------------------- def build_into(self, box: Gtk.Box) -> None: """Populate `box` with one planet button per currently registered tray item. Safe to call repeatedly (dock.py does, on every dock reveal).""" if self._watcher is None: box.append(Gtk.Label(label="No tray watcher running")) return self._ensure_host_registered() entries = self._registered_items() if not entries: box.append(Gtk.Label(label="(empty)")) return for entry in entries: bus, path = self._parse_item(entry) btn = self._build_item_button(bus, path) if btn is not None: box.append(btn) def _build_item_button(self, bus: str, path: str) -> Optional[Gtk.Button]: try: proxy = Gio.DBusProxy.new_for_bus_sync( Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, bus, path, _ITEM_IFACE, None, ) except GLib.Error: return None icon_name = self._prop_str(proxy, "IconName") or "application-x-executable" title = self._prop_str(proxy, "Title") or self._prop_str(proxy, "IconName") or bus btn = Gtk.Button() btn.set_has_frame(False) btn.add_css_class("horizon-planet") btn.add_css_class("horizon-tray-item") btn.set_tooltip_text(title) image = Gtk.Image.new_from_icon_name(icon_name) image.set_pixel_size(20) btn.set_child(image) btn.connect("clicked", lambda *_a, p=proxy: self._activate(p)) right_click = Gtk.GestureClick(button=3) right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p)) btn.add_controller(right_click) return btn @staticmethod def _prop_str(proxy: Gio.DBusProxy, name: str) -> str: value = proxy.get_cached_property(name) return value.unpack() if value is not None else "" @staticmethod def _activate(proxy: Gio.DBusProxy) -> None: proxy.call("Activate", GLib.Variant("(ii)", (0, 0)), Gio.DBusCallFlags.NONE, -1, None, None, None) @staticmethod def _context_menu(proxy: Gio.DBusProxy) -> None: proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)), Gio.DBusCallFlags.NONE, -1, None, None, None)