Files
netbird/client/ui-wails/xembed_host_linux.go
Zoltán Papp 2b272e74c8 [client/ui-wails] In-process StatusNotifierWatcher + XEmbed tray bridge
Wails3's Linux systray hands the icon off to whatever process owns
org.kde.StatusNotifierWatcher on the session bus. Bare WMs (Fluxbox,
OpenBox, i3, dwm, sway, vanilla GNOME without the AppIndicator
extension) ship no watcher, so the icon registration silently fails
and the tray never appears — leaving a tray-only app like NetBird
unreachable.

Add a Linux-only watcher fallback that claims the watcher name when
nobody else does, plus an XEmbed bridge so legacy X11 system trays
(_NET_SYSTEM_TRAY_S0) can still render the icon. Both no-op on other
platforms via build tags.

Pieces:
- tray_watcher_linux.go: claims org.kde.StatusNotifierWatcher on a
  private session bus, exports the bare RegisterStatusNotifierItem /
  RegisterStatusNotifierHost surface, and spins up an XEmbed host per
  registered SNI item.
- xembed_host_linux.go: per-item event loop. Polls X11 events with a
  50ms ticker, listens for the SNI NewIcon signal, dispatches Activate
  / context menu through dbusmenu (com.canonical.dbusmenu).
- xembed_tray_linux.{c,h}: the X11/cairo native bits. Window is created
  with CopyFromParent visual + ParentRelative background so transparent
  pixels show the toolbar beneath instead of solid black on 24-bit
  trays. cairo paints the IconPixmap with OVER blending so per-pixel
  alpha is honoured against the parent-relative base. GTK3 owns the
  context-menu popup; menu items round-trip through dbusmenu Event.
- tray_linux.go: forces WEBKIT_DISABLE_DMABUF_RENDERER=1 in init() so
  developers running `task dev` / launching the binary directly get the
  same software rendering path the .desktop launcher already enables;
  the deb/rpm Exec wrapper covers installed users.
- tray_watcher_other.go and xembed_host_other.go: build-tag stubs so
  main.go's startStatusNotifierWatcher() compiles on every platform.
- main.go: calls startStatusNotifierWatcher() before NewTray so the
  Wails systray's RegisterStatusNotifierItem call hits a watcher we
  control on bare WMs.
- build/linux/netbird-ui.desktop: regenerated by `task build` to wrap
  the dev launcher's Exec line with the WEBKIT_DISABLE_DMABUF_RENDERER
  env, matching what the tray_linux.go init does at runtime.

Adapted from work originally prototyped on the prototype/ui-wails branch.

Tested on Fluxbox (Debian 13): the icon appears in the slit/toolbar with
the toolbar's background showing through transparent pixels, left-click
opens the window, right-click brings up the GTK popup of the dbusmenu
items.
2026-05-06 16:47:35 +02:00

390 lines
8.7 KiB
Go

//go:build linux && !(linux && 386)
package main
/*
#cgo pkg-config: x11 gtk+-3.0 cairo cairo-xlib
#cgo LDFLAGS: -lX11
#include "xembed_tray_linux.h"
#include <X11/Xlib.h>
#include <stdlib.h>
*/
import "C"
import (
"errors"
"sync"
"time"
"unsafe"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
// activeMenuHost is the xembedHost that currently owns the popup menu.
// This is needed because C callbacks cannot carry Go pointers.
var (
activeMenuHost *xembedHost
activeMenuHostMu sync.Mutex
)
//export goMenuItemClicked
func goMenuItemClicked(id C.int) {
activeMenuHostMu.Lock()
h := activeMenuHost
activeMenuHostMu.Unlock()
if h != nil {
go h.sendMenuEvent(int32(id))
}
}
// xembedHost manages one XEmbed tray icon for an SNI item.
type xembedHost struct {
conn *dbus.Conn
busName string
objPath dbus.ObjectPath
dpy *C.Display
trayMgr C.Window
iconWin C.Window
iconSize int
mu sync.Mutex
iconData []byte
iconW int
iconH int
stopCh chan struct{}
}
// newXembedHost creates an XEmbed tray icon for the given SNI item.
// Returns an error if no XEmbed tray manager is available (graceful fallback).
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
dpy := C.XOpenDisplay(nil)
if dpy == nil {
return nil, errors.New("cannot open X display")
}
screen := C.xembed_default_screen(dpy)
trayMgr := C.xembed_find_tray(dpy, screen)
if trayMgr == 0 {
C.XCloseDisplay(dpy)
return nil, errors.New("no XEmbed system tray found")
}
// Query the tray manager's preferred icon size.
iconSize := int(C.xembed_get_icon_size(dpy, trayMgr))
if iconSize <= 0 {
iconSize = 24 // fallback
}
iconWin := C.xembed_create_icon(dpy, screen, C.int(iconSize), trayMgr)
if iconWin == 0 {
C.XCloseDisplay(dpy)
return nil, errors.New("failed to create icon window")
}
if C.xembed_dock(dpy, trayMgr, iconWin) != 0 {
C.xembed_destroy_icon(dpy, iconWin)
C.XCloseDisplay(dpy)
return nil, errors.New("failed to dock icon")
}
h := &xembedHost{
conn: conn,
busName: busName,
objPath: objPath,
dpy: dpy,
trayMgr: trayMgr,
iconWin: iconWin,
iconSize: iconSize,
stopCh: make(chan struct{}),
}
h.fetchAndDrawIcon()
return h, nil
}
// fetchAndDrawIcon reads IconPixmap from the SNI item via D-Bus and draws it.
func (h *xembedHost) fetchAndDrawIcon() {
obj := h.conn.Object(h.busName, h.objPath)
variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap")
if err != nil {
log.Debugf("xembed: failed to get IconPixmap: %v", err)
return
}
// IconPixmap is []struct{W, H int32; Pix []byte} on D-Bus,
// represented as a(iiay) signature.
type px struct {
W int32
H int32
Pix []byte
}
var icons []px
if err := variant.Store(&icons); err != nil {
log.Debugf("xembed: failed to decode IconPixmap: %v", err)
return
}
if len(icons) == 0 {
log.Debug("xembed: IconPixmap is empty")
return
}
icon := icons[0]
if icon.W <= 0 || icon.H <= 0 || len(icon.Pix) < int(icon.W*icon.H*4) {
log.Debug("xembed: invalid IconPixmap data")
return
}
h.mu.Lock()
h.iconData = icon.Pix
h.iconW = int(icon.W)
h.iconH = int(icon.H)
h.mu.Unlock()
h.drawIcon()
}
// drawIcon draws the cached icon data onto the X11 window.
func (h *xembedHost) drawIcon() {
h.mu.Lock()
data := h.iconData
w := h.iconW
ht := h.iconH
h.mu.Unlock()
if data == nil || w <= 0 || ht <= 0 {
return
}
cData := C.CBytes(data)
defer C.free(cData)
C.xembed_draw_icon(h.dpy, h.iconWin, C.int(h.iconSize),
(*C.uchar)(cData), C.int(w), C.int(ht))
}
// run is the main event loop. It polls X11 events and listens for D-Bus
// NewIcon signals to keep the tray icon updated.
func (h *xembedHost) run() {
// Subscribe to NewIcon signals from the SNI item.
matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'"
if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
log.Debugf("xembed: failed to add signal match: %v", err)
}
sigCh := make(chan *dbus.Signal, 16)
h.conn.Signal(sigCh)
defer h.conn.RemoveSignal(sigCh)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-h.stopCh:
return
case sig := <-sigCh:
if sig == nil {
continue
}
if sig.Name == "org.kde.StatusNotifierItem.NewIcon" {
h.fetchAndDrawIcon()
}
case <-ticker.C:
var outX, outY C.int
result := C.xembed_poll_event(h.dpy, h.iconWin, &outX, &outY)
switch result {
case 1: // left click
go h.activate(int32(outX), int32(outY))
case 2: // right click
go h.contextMenu(int32(outX), int32(outY))
case 3: // expose
h.drawIcon()
case 4: // configure (resize)
newSize := int(outX)
if newSize > 0 && newSize != h.iconSize {
h.iconSize = newSize
h.drawIcon()
}
case -1: // tray died
log.Info("xembed: tray manager destroyed, cleaning up")
return
}
}
}
}
func (h *xembedHost) activate(x, y int32) {
obj := h.conn.Object(h.busName, h.objPath)
if err := obj.Call("org.kde.StatusNotifierItem.Activate", 0, x, y).Err; err != nil {
log.Debugf("xembed: Activate call failed: %v", err)
}
}
func (h *xembedHost) contextMenu(x, y int32) {
// Read the menu path from the SNI item's Menu property.
menuPath := dbus.ObjectPath("/StatusNotifierMenu")
// Fetch menu layout from com.canonical.dbusmenu.
menuObj := h.conn.Object(h.busName, menuPath)
var revision uint32
var layout dbusMenuLayout
err := menuObj.Call("com.canonical.dbusmenu.GetLayout", 0,
int32(0), // parentId (root)
int32(-1), // recursionDepth (all)
[]string{}, // propertyNames (all)
).Store(&revision, &layout)
if err != nil {
log.Debugf("xembed: GetLayout failed: %v", err)
return
}
items := h.flattenMenu(layout)
log.Debugf("xembed: menu has %d items (revision %d)", len(items), revision)
if len(items) == 0 {
return
}
// Build C menu item array.
cItems := make([]C.xembed_menu_item, len(items))
cLabels := make([]*C.char, len(items)) // track for freeing
for i, mi := range items {
cItems[i].id = C.int(mi.id)
cItems[i].enabled = boolToInt(mi.enabled)
cItems[i].is_check = boolToInt(mi.isCheck)
cItems[i].checked = boolToInt(mi.checked)
cItems[i].is_separator = boolToInt(mi.isSeparator)
if mi.label != "" {
cLabels[i] = C.CString(mi.label)
cItems[i].label = cLabels[i]
}
}
defer func() {
for _, p := range cLabels {
if p != nil {
C.free(unsafe.Pointer(p))
}
}
}()
// Set the active menu host so the C callback can reach us.
activeMenuHostMu.Lock()
activeMenuHost = h
activeMenuHostMu.Unlock()
C.xembed_show_popup_menu(&cItems[0], C.int(len(cItems)),
nil, C.int(x), C.int(y))
}
// dbusMenuLayout represents a com.canonical.dbusmenu layout item.
type dbusMenuLayout struct {
ID int32
Properties map[string]dbus.Variant
Children []dbus.Variant
}
type menuItemInfo struct {
id int32
label string
enabled bool
isCheck bool
checked bool
isSeparator bool
}
func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo {
var items []menuItemInfo
for _, childVar := range layout.Children {
var child dbusMenuLayout
if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil {
continue
}
mi := menuItemInfo{
id: child.ID,
enabled: true,
}
if v, ok := child.Properties["type"]; ok {
if s, ok := v.Value().(string); ok && s == "separator" {
mi.isSeparator = true
items = append(items, mi)
continue
}
}
if v, ok := child.Properties["label"]; ok {
if s, ok := v.Value().(string); ok {
mi.label = s
}
}
if v, ok := child.Properties["enabled"]; ok {
if b, ok := v.Value().(bool); ok {
mi.enabled = b
}
}
if v, ok := child.Properties["visible"]; ok {
if b, ok := v.Value().(bool); ok && !b {
continue // skip hidden items
}
}
if v, ok := child.Properties["toggle-type"]; ok {
if s, ok := v.Value().(string); ok && s == "checkmark" {
mi.isCheck = true
}
}
if v, ok := child.Properties["toggle-state"]; ok {
if n, ok := v.Value().(int32); ok && n == 1 {
mi.checked = true
}
}
items = append(items, mi)
}
return items
}
func (h *xembedHost) sendMenuEvent(id int32) {
menuPath := dbus.ObjectPath("/StatusNotifierMenu")
menuObj := h.conn.Object(h.busName, menuPath)
data := dbus.MakeVariant("")
err := menuObj.Call("com.canonical.dbusmenu.Event", 0,
id, "clicked", data, uint32(0)).Err
if err != nil {
log.Debugf("xembed: menu Event call failed: %v", err)
}
}
func boolToInt(b bool) C.int {
if b {
return 1
}
return 0
}
func (h *xembedHost) stop() {
select {
case <-h.stopCh:
return // already stopped
default:
close(h.stopCh)
}
C.xembed_destroy_icon(h.dpy, h.iconWin)
C.XCloseDisplay(h.dpy)
}