Files
netbird/client/ui/tray_watcher_linux.go
Zoltán Papp 072d789463 client/ui: retry XEmbed-tray probe before claiming SNI watcher
The XEmbed tray (panel) can come up after the autostarted UI on minimal
WMs, so the single startup probe added in #6320 could miss a tray that
appears a second or two later, leaving the icon silently absent. Re-probe
for a ~10s grace period in a goroutine, claiming the watcher as soon as a
tray shows up; back off cleanly if none ever appears (headless/Wayland).
2026-06-01 21:55:04 +02:00

200 lines
7.2 KiB
Go

//go:build linux && !(linux && 386)
package main
// startStatusNotifierWatcher registers org.kde.StatusNotifierWatcher on the
// session D-Bus if no other process has already claimed it.
//
// Minimal window managers (Fluxbox, OpenBox, i3, etc.) do not ship a
// StatusNotifier watcher, so tray icons using libayatana-appindicator or
// the KDE/freedesktop StatusNotifier protocol silently fail.
//
// By owning the watcher name in-process we allow the Wails v3 built-in tray
// to register itself — no external daemon or package needed.
//
// When an XEmbed system tray is available (_NET_SYSTEM_TRAY_S0), we also
// start an in-process XEmbed host that bridges the SNI icon into the
// XEmbed tray (Fluxbox, IceWM, etc.).
import (
"sync"
"time"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
const (
watcherName = "org.kde.StatusNotifierWatcher"
watcherPath = "/StatusNotifierWatcher"
watcherIface = "org.kde.StatusNotifierWatcher"
// watcherProbeInterval / watcherProbeTimeout bound how long we keep
// re-probing for an XEmbed tray before giving up. The UI is commonly
// autostarted *before* the panel/tray on minimal WMs, so a single probe
// at startup would miss a tray that comes up a second or two later and
// the icon would silently never appear. ~10s of polling covers a slow
// panel launch while staying short enough that a headless / pure-Wayland
// session (no XEmbed tray ever) winds down quickly.
watcherProbeInterval = 500 * time.Millisecond
watcherProbeTimeout = 10 * time.Second
)
type statusNotifierWatcher struct {
conn *dbus.Conn
items []string
hosts map[string]*xembedHost
hostsMu sync.Mutex
}
// RegisterStatusNotifierItem is the D-Bus method called by tray clients.
// The sender parameter is automatically injected by godbus with the caller's
// unique bus name (e.g. ":1.42"). It does not appear in the D-Bus signature.
func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error {
for _, s := range w.items {
if s == service {
return nil
}
}
w.items = append(w.items, service)
log.Debugf("StatusNotifierWatcher: registered item %q from %s", service, sender)
go w.tryStartXembedHost(string(sender), dbus.ObjectPath(service))
return nil
}
// RegisterStatusNotifierHost is required by the protocol but unused here.
func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus.Error {
log.Debugf("StatusNotifierWatcher: host registered %q", service)
return nil
}
// tryStartXembedHost attempts to create an XEmbed tray icon for the given
// SNI item. If no XEmbed tray manager is available, this is a no-op.
func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) {
w.hostsMu.Lock()
defer w.hostsMu.Unlock()
if _, exists := w.hosts[busName]; exists {
return
}
// Use a private session bus so our signal subscriptions don't
// interfere with Wails' signal handler (which panics on unexpected signals).
sessionConn, err := dbus.SessionBusPrivate()
if err != nil {
log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err)
return
}
if err := sessionConn.Auth(nil); err != nil {
log.Debugf("StatusNotifierWatcher: XEmbed host auth failed: %v", err)
_ = sessionConn.Close()
return
}
if err := sessionConn.Hello(); err != nil {
log.Debugf("StatusNotifierWatcher: XEmbed host Hello failed: %v", err)
_ = sessionConn.Close()
return
}
host, err := newXembedHost(sessionConn, busName, objPath)
if err != nil {
log.Debugf("StatusNotifierWatcher: XEmbed host not started: %v", err)
return
}
w.hosts[busName] = host
go host.run()
log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName)
}
// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher on the
// session bus, but ONLY as a bridge to an XEmbed system tray on minimal WMs.
//
// The in-process watcher is a stub: its RegisterStatusNotifierItem only
// tracks items so it can mirror them into an XEmbed tray icon — it does
// NOT relay them to any other StatusNotifierHost. So if we claim the name
// on a desktop that has a real watcher/host (e.g. Hyprland + Waybar), every
// other tray app (Slack, etc.) registers into our dead-end watcher and its
// icon never reaches the real host. We won that name purely by starting
// first; a GetNameOwner check doesn't help against a login-order race.
//
// The correct discriminator is whether an XEmbed tray actually exists. If
// one does, we are the bridge of last resort and should claim the watcher.
// If not (pure Wayland, or any environment already running a real watcher),
// we have nothing to bridge and must stay off the bus entirely so the real
// watcher owns the name. Safe to call unconditionally.
//
// The XEmbed tray may come up *after* the UI (the panel and the autostarted
// app race at login), so we re-probe for a short grace period instead of
// deciding once at startup. The probing runs in a goroutine so it never
// blocks the caller's startup path.
func startStatusNotifierWatcher() {
go func() {
deadline := time.Now().Add(watcherProbeTimeout)
for {
if xembedTrayAvailable() {
claimStatusNotifierWatcher()
return
}
if time.Now().After(deadline) {
log.Debugf("StatusNotifierWatcher: no XEmbed tray appeared within %s, leaving the watcher to the desktop", watcherProbeTimeout)
return
}
time.Sleep(watcherProbeInterval)
}
}()
}
// claimStatusNotifierWatcher opens a private session-bus connection and takes
// ownership of org.kde.StatusNotifierWatcher, exporting the in-process stub
// watcher. The caller has already confirmed an XEmbed tray is present, so we
// genuinely have an item to bridge. The GetNameOwner / DoNotQueue guards still
// back off if a real watcher already holds the name.
func claimStatusNotifierWatcher() {
conn, err := dbus.SessionBusPrivate()
if err != nil {
log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err)
return
}
if err := conn.Auth(nil); err != nil {
log.Debugf("StatusNotifierWatcher: auth failed: %v", err)
_ = conn.Close()
return
}
if err := conn.Hello(); err != nil {
log.Debugf("StatusNotifierWatcher: Hello failed: %v", err)
_ = conn.Close()
return
}
// Check whether another process already owns the watcher name.
var owner string
callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner)
if callErr == nil && owner != "" {
log.Debugf("StatusNotifierWatcher: already owned by %s, skipping", owner)
_ = conn.Close()
return
}
reply, err := conn.RequestName(watcherName, dbus.NameFlagDoNotQueue)
if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
log.Debugf("StatusNotifierWatcher: could not claim name (reply=%v err=%v)", reply, err)
_ = conn.Close()
return
}
w := &statusNotifierWatcher{
conn: conn,
hosts: make(map[string]*xembedHost),
}
if err := conn.ExportAll(w, dbus.ObjectPath(watcherPath), watcherIface); err != nil {
log.Errorf("StatusNotifierWatcher: export failed: %v", err)
_ = conn.Close()
return
}
log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)")
// Connection intentionally kept open for the lifetime of the process.
}