//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. }