fix(hyprdrive/station-bar): host a StatusNotifierWatcher so the tray fills

hyprdrive has no DE component owning org.kde.StatusNotifierWatcher (waybar/eww
used to), so tray apps had nowhere to register and every SNI host — including the
bar's own tray.py — saw an empty tray.

- sni_watcher.py: a minimal StatusNotifierWatcher — owns the name, tracks
  registered items/hosts, drops items on NameOwnerChanged, and emits the
  registration signals + PropertiesChanged so hosts stay live.
- main.py: start it in do_startup, before the bars.
- tray.py: watch the watcher name (bus_watch_name) instead of connecting once, so
  the host attaches when our watcher appears and recovers if any watcher restarts.

Verified: the watcher owns the bus name, IsStatusNotifierHostRegistered is true,
and running tray apps (udiskie, nm-applet, blueman, Steam) register + render in
the bar the moment it comes up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-18 17:44:17 +02:00
parent 8dfd505ce4
commit a534952c1c
3 changed files with 167 additions and 6 deletions

View File

@ -35,6 +35,7 @@ import config # noqa: E402
import theme # noqa: E402 import theme # noqa: E402
from bar import StationBar # noqa: E402 from bar import StationBar # noqa: E402
from paths import APP_ID # noqa: E402 from paths import APP_ID # noqa: E402
from sni_watcher import SniWatcher # noqa: E402
class StationBarApp(Gtk.Application): class StationBarApp(Gtk.Application):
@ -46,10 +47,14 @@ class StationBarApp(Gtk.Application):
self.bars: dict[str, StationBar] = {} self.bars: dict[str, StationBar] = {}
self._hologram = True self._hologram = True
self._visible = True self._visible = True
self._sni_watcher: SniWatcher | None = None
def do_startup(self) -> None: def do_startup(self) -> None:
Gtk.Application.do_startup(self) Gtk.Application.do_startup(self)
theme.load_css() theme.load_css()
# Provide the StatusNotifierWatcher ourselves (no DE does it here), so tray
# apps have something to register with and the bars' tray fills up.
self._sni_watcher = SniWatcher()
self._hologram = config.hologram_enabled() self._hologram = config.hologram_enabled()
display = Gdk.Display.get_default() display = Gdk.Display.get_default()
if display is not None: if display is not None:

View File

@ -0,0 +1,147 @@
"""A minimal StatusNotifierWatcher (org.kde.StatusNotifierWatcher).
Under a full desktop this bus name is provided by the panel/DE; hyprdrive has no
such component (we replaced waybar/eww), so tray apps have nowhere to register
and every SNI host including our own bar's tray.py — sees an empty tray. This
owns the name, tracks registered items/hosts, and emits the registration signals
+ PropertiesChanged so hosts stay live as apps come and go.
Kept deliberately small: it does not proxy the items themselves (the host talks
to each StatusNotifierItem directly), it just is the registry the spec requires.
"""
from __future__ import annotations
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
_NAME = "org.kde.StatusNotifierWatcher"
_PATH = "/StatusNotifierWatcher"
_IFACE = "org.kde.StatusNotifierWatcher"
_XML = f"""
<node>
<interface name="{_IFACE}">
<method name="RegisterStatusNotifierItem">
<arg name="service" type="s" direction="in"/>
</method>
<method name="RegisterStatusNotifierHost">
<arg name="service" type="s" direction="in"/>
</method>
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered"><arg name="service" type="s"/></signal>
<signal name="StatusNotifierItemUnregistered"><arg name="service" type="s"/></signal>
<signal name="StatusNotifierHostRegistered"/>
<signal name="StatusNotifierHostUnregistered"/>
</interface>
</node>
"""
class SniWatcher:
def __init__(self) -> None:
self._conn: Gio.DBusConnection | None = None
self._items: dict[str, str] = {} # "service/path" -> owner unique name
self._hosts: set[str] = set()
self._reg_id = 0
# NAME_OWNER flags let a real DE watcher take over cleanly if one appears.
self._owner_id = Gio.bus_own_name(
Gio.BusType.SESSION, _NAME,
Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT,
self._on_bus_acquired, None, self._on_name_lost,
)
# -- bus lifecycle -----------------------------------------------------
def _on_bus_acquired(self, conn: Gio.DBusConnection, _name: str) -> None:
self._conn = conn
info = Gio.DBusNodeInfo.new_for_xml(_XML).interfaces[0]
try:
self._reg_id = conn.register_object(
_PATH, info, self._on_method, self._on_get_property, None)
except GLib.Error:
return
# drop items whose owner leaves the bus
conn.signal_subscribe(
"org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
"/org/freedesktop/DBus", None, Gio.DBusSignalFlags.NONE,
self._on_name_owner_changed)
def _on_name_lost(self, _conn, _name) -> None:
# another watcher grabbed the name — step aside quietly
self._conn = None
# -- method / property handlers ---------------------------------------
def _on_method(self, conn, sender, _path, _iface, method, params, invocation) -> None:
if method == "RegisterStatusNotifierItem":
self._register_item(sender, params.unpack()[0])
invocation.return_value(None)
elif method == "RegisterStatusNotifierHost":
self._hosts.add(params.unpack()[0])
conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierHostRegistered", None)
self._emit_props({"IsStatusNotifierHostRegistered": GLib.Variant("b", True)})
invocation.return_value(None)
else:
invocation.return_value(None)
def _on_get_property(self, _conn, _sender, _path, _iface, prop):
if prop == "RegisteredStatusNotifierItems":
return GLib.Variant("as", list(self._items.keys()))
if prop == "IsStatusNotifierHostRegistered":
return GLib.Variant("b", bool(self._hosts))
if prop == "ProtocolVersion":
return GLib.Variant("i", 0)
return None
# -- registry bookkeeping ---------------------------------------------
@staticmethod
def _entry_for(sender: str, service: str) -> str:
# apps register either an object path (use the caller as the service),
# a full "busname/path", or just a bus name (default item path).
if service.startswith("/"):
return sender + service
if "/" in service:
return service
return service + "/StatusNotifierItem"
def _register_item(self, sender: str, service: str) -> None:
if self._conn is None:
return
entry = self._entry_for(sender, service)
if entry in self._items:
return
self._items[entry] = sender
self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemRegistered",
GLib.Variant("(s)", (entry,)))
self._emit_items_changed()
def _on_name_owner_changed(self, _conn, _sender, _path, _iface, _signal, params) -> None:
name, _old, new_owner = params.unpack()
if new_owner:
return # a name was acquired, not lost
gone = [e for e, owner in self._items.items()
if owner == name or e.split("/", 1)[0] == name]
for e in gone:
del self._items[e]
if self._conn is not None:
self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemUnregistered",
GLib.Variant("(s)", (e,)))
if name in self._hosts:
self._hosts.discard(name)
if gone:
self._emit_items_changed()
# -- signalling --------------------------------------------------------
def _emit_items_changed(self) -> None:
self._emit_props({"RegisteredStatusNotifierItems":
GLib.Variant("as", list(self._items.keys()))})
def _emit_props(self, changed: dict) -> None:
if self._conn is None:
return
self._conn.emit_signal(
None, _PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (_IFACE, changed, [])))

View File

@ -27,22 +27,31 @@ class TrayHost:
self._on_change = on_change self._on_change = on_change
self._watcher: Optional[Gio.DBusProxy] = None self._watcher: Optional[Gio.DBusProxy] = None
self._registered_host = False self._registered_host = False
self._connect() # Watch the name rather than connecting once: our own SniWatcher may not
# own it yet at construction, and this also recovers if the watcher (ours
# or a real DE's) restarts.
self._watch_id = Gio.bus_watch_name(
Gio.BusType.SESSION, _WATCHER_BUS, Gio.BusNameWatcherFlags.NONE,
self._on_watcher_appeared, self._on_watcher_vanished)
def _connect(self) -> None: def _on_watcher_appeared(self, _conn, _name, _owner) -> None:
try: try:
proxy = Gio.DBusProxy.new_for_bus_sync( self._watcher = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
_WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None, _WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None,
) )
self._watcher = proxy if proxy.get_name_owner() is not None else None
except GLib.Error: except GLib.Error:
self._watcher = None self._watcher = None
return return
if self._watcher is None:
return
self._watcher.connect("g-properties-changed", lambda *_a: self._on_change()) self._watcher.connect("g-properties-changed", lambda *_a: self._on_change())
self._registered_host = False
self._ensure_host_registered() self._ensure_host_registered()
self._on_change()
def _on_watcher_vanished(self, _conn, _name) -> None:
self._watcher = None
self._registered_host = False
self._on_change()
def _ensure_host_registered(self) -> None: def _ensure_host_registered(self) -> None:
if self._registered_host or self._watcher is None: if self._registered_host or self._watcher is None: