[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.
This commit is contained in:
Zoltán Papp
2026-05-06 16:47:35 +02:00
parent e6cbf30415
commit 2b272e74c8
9 changed files with 1043 additions and 1 deletions

View File

@@ -1,7 +1,7 @@
[Desktop Entry]
Type=Application
Name=netbird-ui
Exec=netbird-ui
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
Icon=netbird-ui
Categories=Development;
Terminal=false

View File

@@ -130,6 +130,13 @@ func main() {
window.Hide()
})
// Register an in-process StatusNotifierWatcher so the tray works on
// minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the
// AppIndicator extension) that don't ship one themselves. No-op on
// non-Linux platforms. Must run before NewTray so the Wails systray's
// RegisterStatusNotifierItem call hits a watcher we control.
startStatusNotifierWatcher()
tray = NewTray(app, window, TrayServices{
Connection: connection,
Settings: settings,

View File

@@ -0,0 +1,24 @@
//go:build linux && !386
package main
import "os"
// init runs before Wails' own init(), so the env var is set in time.
func init() {
if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" {
return
}
// WebKitGTK's DMA-BUF renderer fails on many setups (VMs, containers,
// minimal WMs without proper GPU access) and leaves the window blank
// white. Wails only disables it for NVIDIA+Wayland, but the issue is
// broader. Always disable it — software rendering works fine for a
// small UI like this.
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
// On Linux, the system tray provider may require the menu to be recreated
// rather than updated in place. The rebuildExitNodeMenu method in tray.go
// already handles this by removing and re-adding items; no additional
// Linux-specific workaround is needed for Wails v3.

View File

@@ -0,0 +1,148 @@
//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"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
const (
watcherName = "org.kde.StatusNotifierWatcher"
watcherPath = "/StatusNotifierWatcher"
watcherIface = "org.kde.StatusNotifierWatcher"
)
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 if it is not already provided by another process.
// Safe to call unconditionally — it does nothing when a real watcher is present.
func startStatusNotifierWatcher() {
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.
}

View File

@@ -0,0 +1,6 @@
//go:build !linux || (linux && 386)
package main
// startStatusNotifierWatcher is a no-op on non-Linux platforms.
func startStatusNotifierWatcher() {}

View File

@@ -0,0 +1,389 @@
//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)
}

View File

@@ -0,0 +1,18 @@
//go:build !linux || (linux && 386)
package main
import (
"errors"
"github.com/godbus/dbus/v5"
)
type xembedHost struct{}
func newXembedHost(_ *dbus.Conn, _ string, _ dbus.ObjectPath) (*xembedHost, error) {
return nil, errors.New("XEmbed tray not supported on this platform")
}
func (h *xembedHost) run() {}
func (h *xembedHost) stop() {}

View File

@@ -0,0 +1,379 @@
#include "xembed_tray_linux.h"
#include <X11/Xatom.h>
#include <X11/Xutil.h>
#include <cairo/cairo-xlib.h>
#include <cairo/cairo.h>
#include <gtk/gtk.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#define SYSTEM_TRAY_REQUEST_DOCK 0
#define XEMBED_MAPPED (1 << 0)
Window xembed_find_tray(Display *dpy, int screen) {
char atom_name[64];
snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen);
Atom sel = XInternAtom(dpy, atom_name, False);
return XGetSelectionOwner(dpy, sel);
}
int xembed_get_icon_size(Display *dpy, Window tray_mgr) {
Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_ICON_SIZE", False);
Atom actual_type;
int actual_format;
unsigned long nitems, bytes_after;
unsigned char *prop = NULL;
int size = 0;
if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False,
XA_CARDINAL, &actual_type, &actual_format,
&nitems, &bytes_after, &prop) == Success) {
if (prop && nitems == 1 && actual_format == 32) {
size = (int)(*(unsigned long *)prop);
}
if (prop)
XFree(prop);
}
return size;
}
Window xembed_create_icon(Display *dpy, int screen, int size,
Window tray_mgr) {
(void)tray_mgr; /* unused; kept in signature for caller symmetry */
Window root = RootWindow(dpy, screen);
/* Inherit visual & depth from the parent (tray manager / root) so
ParentRelative background works on every tray. Many minimal
toolbars (Fluxbox slit, OpenBox, etc.) only offer a 24-bit
default visual and do not composite alpha; ParentRelative makes
the X server texture this window's background from the parent,
so transparent pixels in the icon show the toolbar beneath
instead of solid black. ARGB-aware trays still work because the
cairo OVER blend in xembed_draw_icon honours per-pixel alpha
against whatever base the X server painted underneath. */
XSetWindowAttributes attrs;
memset(&attrs, 0, sizeof(attrs));
attrs.event_mask = ButtonPressMask | StructureNotifyMask | ExposureMask;
attrs.background_pixmap = ParentRelative;
unsigned long mask = CWEventMask | CWBackPixmap;
Window win = XCreateWindow(
dpy, root,
0, 0, size, size,
0, /* border width */
CopyFromParent, /* depth */
InputOutput,
CopyFromParent, /* visual */
mask,
&attrs
);
/* Set _XEMBED_INFO: version=0, flags=XEMBED_MAPPED */
Atom xembed_info = XInternAtom(dpy, "_XEMBED_INFO", False);
unsigned long info[2] = { 0, XEMBED_MAPPED };
XChangeProperty(dpy, win, xembed_info, xembed_info,
32, PropModeReplace, (unsigned char *)info, 2);
return win;
}
int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win) {
Atom opcode = XInternAtom(dpy, "_NET_SYSTEM_TRAY_OPCODE", False);
XClientMessageEvent ev;
memset(&ev, 0, sizeof(ev));
ev.type = ClientMessage;
ev.window = tray_mgr;
ev.message_type = opcode;
ev.format = 32;
ev.data.l[0] = CurrentTime;
ev.data.l[1] = SYSTEM_TRAY_REQUEST_DOCK;
ev.data.l[2] = (long)icon_win;
XSendEvent(dpy, tray_mgr, False, NoEventMask, (XEvent *)&ev);
XFlush(dpy);
return 0;
}
void xembed_draw_icon(Display *dpy, Window icon_win, int win_size,
const unsigned char *data, int img_w, int img_h) {
if (!data || img_w <= 0 || img_h <= 0 || win_size <= 0)
return;
/* Query the window's actual visual and depth so cairo composites
through the matching ARGB pipeline. */
XWindowAttributes wa;
if (!XGetWindowAttributes(dpy, icon_win, &wa))
return;
/* Build a CAIRO_FORMAT_ARGB32 source surface from the SNI IconPixmap
bytes. SNI ships the pixels as [A,R,G,B,...] in network byte
order; cairo's ARGB32 stores native uint32 with B in the lowest
byte on little-endian hosts. Repack into native order with
pre-multiplied alpha so cairo can composite without tonemapping. */
int stride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, img_w);
unsigned char *buf = (unsigned char *)calloc(stride * img_h, 1);
if (!buf)
return;
for (int y = 0; y < img_h; y++) {
unsigned int *row = (unsigned int *)(buf + y * stride);
for (int x = 0; x < img_w; x++) {
int idx = (y * img_w + x) * 4;
unsigned int a = data[idx + 0];
unsigned int r = data[idx + 1];
unsigned int g = data[idx + 2];
unsigned int b = data[idx + 3];
if (a == 0) {
row[x] = 0;
} else if (a == 255) {
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
} else {
unsigned int pr = r * a / 255;
unsigned int pg = g * a / 255;
unsigned int pb = b * a / 255;
row[x] = (a << 24) | (pr << 16) | (pg << 8) | pb;
}
}
}
cairo_surface_t *src = cairo_image_surface_create_for_data(
buf, CAIRO_FORMAT_ARGB32, img_w, img_h, stride);
if (cairo_surface_status(src) != CAIRO_STATUS_SUCCESS) {
cairo_surface_destroy(src);
free(buf);
return;
}
/* Wrap the X11 window in a cairo XLib surface using its real visual. */
cairo_surface_t *dst = cairo_xlib_surface_create(
dpy, icon_win, wa.visual, win_size, win_size);
if (cairo_surface_status(dst) != CAIRO_STATUS_SUCCESS) {
cairo_surface_destroy(dst);
cairo_surface_destroy(src);
free(buf);
return;
}
/* Repaint the ParentRelative background first — without this the
window keeps the previously-drawn icon underneath when an icon
update arrives, and cairo's OVER blend would composite the new
icon on top of the stale one. XClearWindow forces the X server
to retexture from the parent (tray toolbar), giving us a clean
opaque base. */
XClearWindow(dpy, icon_win);
cairo_t *cr = cairo_create(dst);
/* Scale the source onto the window with alpha compositing (default
OPERATOR_OVER). Transparent pixels keep the toolbar's pixels
visible underneath. */
double sx = (double)win_size / img_w;
double sy = (double)win_size / img_h;
cairo_scale(cr, sx, sy);
cairo_set_source_surface(cr, src, 0, 0);
cairo_paint(cr);
cairo_destroy(cr);
cairo_surface_destroy(dst);
cairo_surface_destroy(src);
free(buf);
XFlush(dpy);
}
void xembed_destroy_icon(Display *dpy, Window icon_win) {
if (icon_win)
XDestroyWindow(dpy, icon_win);
XFlush(dpy);
}
int xembed_poll_event(Display *dpy, Window icon_win,
int *out_x, int *out_y) {
*out_x = 0;
*out_y = 0;
while (XPending(dpy) > 0) {
XEvent ev;
XNextEvent(dpy, &ev);
switch (ev.type) {
case ButtonPress:
if (ev.xbutton.window == icon_win) {
*out_x = ev.xbutton.x_root;
*out_y = ev.xbutton.y_root;
if (ev.xbutton.button == Button1)
return 1;
if (ev.xbutton.button == Button3)
return 2;
}
break;
case Expose:
if (ev.xexpose.window == icon_win && ev.xexpose.count == 0)
return 3;
break;
case DestroyNotify:
if (ev.xdestroywindow.window == icon_win)
return -1;
break;
case ConfigureNotify:
if (ev.xconfigure.window == icon_win) {
*out_x = ev.xconfigure.width;
*out_y = ev.xconfigure.height;
return 4;
}
break;
case ReparentNotify:
/* Tray manager reparented us — this is expected after docking. */
break;
default:
break;
}
}
return 0;
}
/* --- GTK3 popup window menu support --- */
/* Implemented in Go via //export */
extern void goMenuItemClicked(int id);
/* The popup window, reused across invocations. */
static GtkWidget *popup_win = NULL;
typedef struct {
xembed_menu_item *items;
int count;
int x, y;
} popup_data;
static void free_popup_data(popup_data *pd) {
if (!pd) return;
for (int i = 0; i < pd->count; i++)
free((void *)pd->items[i].label);
free(pd->items);
free(pd);
}
static void on_button_clicked(GtkButton *btn, gpointer user_data) {
int id = GPOINTER_TO_INT(user_data);
if (popup_win)
gtk_widget_hide(popup_win);
goMenuItemClicked(id);
}
static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) {
int id = GPOINTER_TO_INT(user_data);
if (popup_win)
gtk_widget_hide(popup_win);
goMenuItemClicked(id);
}
static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event,
gpointer user_data) {
gtk_widget_hide(widget);
return FALSE;
}
static gboolean popup_menu_idle(gpointer user_data) {
popup_data *pd = (popup_data *)user_data;
/* Destroy old popup if it exists. */
if (popup_win) {
gtk_widget_destroy(popup_win);
popup_win = NULL;
}
popup_win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_type_hint(GTK_WINDOW(popup_win),
GDK_WINDOW_TYPE_HINT_POPUP_MENU);
gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE);
gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE);
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup_win), TRUE);
gtk_window_set_skip_pager_hint(GTK_WINDOW(popup_win), TRUE);
gtk_window_set_keep_above(GTK_WINDOW(popup_win), TRUE);
/* Close on focus loss. */
g_signal_connect(popup_win, "focus-out-event",
G_CALLBACK(on_popup_focus_out), NULL);
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(popup_win), vbox);
for (int i = 0; i < pd->count; i++) {
xembed_menu_item *mi = &pd->items[i];
if (mi->is_separator) {
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
continue;
}
if (mi->is_check) {
GtkWidget *chk = gtk_check_button_new_with_label(
mi->label ? mi->label : "");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
gtk_widget_set_sensitive(chk, mi->enabled);
g_signal_connect(chk, "toggled",
G_CALLBACK(on_check_toggled),
GINT_TO_POINTER(mi->id));
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
} else {
GtkWidget *btn = gtk_button_new_with_label(
mi->label ? mi->label : "");
gtk_widget_set_sensitive(btn, mi->enabled);
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
/* Left-align label. */
GtkWidget *label = gtk_bin_get_child(GTK_BIN(btn));
if (label)
gtk_label_set_xalign(GTK_LABEL(label), 0.0);
g_signal_connect(btn, "clicked",
G_CALLBACK(on_button_clicked),
GINT_TO_POINTER(mi->id));
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
}
}
gtk_widget_show_all(popup_win);
/* Position the window above the click point (menu grows upward from tray). */
gint win_w, win_h;
gtk_window_get_size(GTK_WINDOW(popup_win), &win_w, &win_h);
int final_x = pd->x - win_w / 2;
int final_y = pd->y - win_h;
if (final_x < 0) final_x = 0;
if (final_y < 0) final_y = pd->y; /* fallback: below click */
gtk_window_move(GTK_WINDOW(popup_win), final_x, final_y);
/* Grab focus so focus-out-event works. */
gtk_window_present(GTK_WINDOW(popup_win));
free_popup_data(pd);
return G_SOURCE_REMOVE;
}
void xembed_show_popup_menu(xembed_menu_item *items, int count,
xembed_menu_click_cb cb, int x, int y) {
(void)cb;
popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data));
pd->items = (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item));
pd->count = count;
pd->x = x;
pd->y = y;
for (int i = 0; i < count; i++) {
pd->items[i] = items[i];
if (items[i].label)
pd->items[i].label = strdup(items[i].label);
}
g_idle_add(popup_menu_idle, pd);
}

View File

@@ -0,0 +1,71 @@
#ifndef XEMBED_TRAY_H
#define XEMBED_TRAY_H
#include <X11/Xlib.h>
// xembed_default_screen wraps the DefaultScreen macro for CGo.
static inline int xembed_default_screen(Display *dpy) {
return DefaultScreen(dpy);
}
// xembed_find_tray returns the selection owner window for
// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists.
Window xembed_find_tray(Display *dpy, int screen);
// xembed_get_icon_size queries _NET_SYSTEM_TRAY_ICON_SIZE from the tray
// manager window. Returns the size in pixels, or 0 if not set.
int xembed_get_icon_size(Display *dpy, Window tray_mgr);
// xembed_create_icon creates a tray icon window of the given size,
// sets _XEMBED_INFO, and returns the window ID.
// tray_mgr is the tray manager window; its _NET_SYSTEM_TRAY_VISUAL
// property is queried to obtain a 32-bit ARGB visual for transparency.
Window xembed_create_icon(Display *dpy, int screen, int size, Window tray_mgr);
// xembed_dock sends _NET_SYSTEM_TRAY_OPCODE SYSTEM_TRAY_REQUEST_DOCK
// to the tray manager to embed our icon window.
int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win);
// xembed_draw_icon draws ARGB pixel data onto the icon window.
// data is in [A,R,G,B] byte order per pixel (SNI IconPixmap format).
// img_w, img_h are the source image dimensions.
// win_size is the target window dimension (square).
void xembed_draw_icon(Display *dpy, Window icon_win, int win_size,
const unsigned char *data, int img_w, int img_h);
// xembed_destroy_icon destroys the icon window.
void xembed_destroy_icon(Display *dpy, Window icon_win);
// xembed_poll_event processes pending X11 events. Returns:
// 0 = no actionable event
// 1 = left button press (out_x, out_y filled)
// 2 = right button press (out_x, out_y filled)
// 3 = expose (needs redraw)
// 4 = configure (resize; out_x=width, out_y=height)
// -1 = DestroyNotify on icon window (tray died)
int xembed_poll_event(Display *dpy, Window icon_win,
int *out_x, int *out_y);
// Callback type for menu item clicks. Called with the item's dbusmenu ID.
typedef void (*xembed_menu_click_cb)(int id);
// xembed_popup_menu builds and shows a GTK3 popup menu.
// items is an array of menu item descriptors, count is the number of items.
// cb is called (from the GTK main thread) when an item is clicked.
// x, y are root coordinates for positioning the popup.
// This must be called from the GTK main thread (use g_idle_add).
typedef struct {
int id; // dbusmenu item ID
const char *label; // display label (NULL for separator)
int enabled; // whether the item is clickable
int is_check; // whether this is a checkbox item
int checked; // checkbox state (0 or 1)
int is_separator;// 1 if this is a separator
} xembed_menu_item;
// Schedule a GTK popup menu on the main thread.
void xembed_show_popup_menu(xembed_menu_item *items, int count,
xembed_menu_click_cb cb, int x, int y);
#endif