diff --git a/client/ui-wails/build/linux/netbird-ui.desktop b/client/ui-wails/build/linux/netbird-ui.desktop index a46e530c1..6b6ed42a5 100755 --- a/client/ui-wails/build/linux/netbird-ui.desktop +++ b/client/ui-wails/build/linux/netbird-ui.desktop @@ -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 diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go index bd7fa4674..e5f4d4a7a 100644 --- a/client/ui-wails/main.go +++ b/client/ui-wails/main.go @@ -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, diff --git a/client/ui-wails/tray_linux.go b/client/ui-wails/tray_linux.go new file mode 100644 index 000000000..a213ce2f9 --- /dev/null +++ b/client/ui-wails/tray_linux.go @@ -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. diff --git a/client/ui-wails/tray_watcher_linux.go b/client/ui-wails/tray_watcher_linux.go new file mode 100644 index 000000000..7a9b72096 --- /dev/null +++ b/client/ui-wails/tray_watcher_linux.go @@ -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. +} diff --git a/client/ui-wails/tray_watcher_other.go b/client/ui-wails/tray_watcher_other.go new file mode 100644 index 000000000..aa8fc8ac7 --- /dev/null +++ b/client/ui-wails/tray_watcher_other.go @@ -0,0 +1,6 @@ +//go:build !linux || (linux && 386) + +package main + +// startStatusNotifierWatcher is a no-op on non-Linux platforms. +func startStatusNotifierWatcher() {} diff --git a/client/ui-wails/xembed_host_linux.go b/client/ui-wails/xembed_host_linux.go new file mode 100644 index 000000000..33d247b18 --- /dev/null +++ b/client/ui-wails/xembed_host_linux.go @@ -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 +#include +*/ +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) +} diff --git a/client/ui-wails/xembed_host_other.go b/client/ui-wails/xembed_host_other.go new file mode 100644 index 000000000..c93d78413 --- /dev/null +++ b/client/ui-wails/xembed_host_other.go @@ -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() {} diff --git a/client/ui-wails/xembed_tray_linux.c b/client/ui-wails/xembed_tray_linux.c new file mode 100644 index 000000000..2db6a4e45 --- /dev/null +++ b/client/ui-wails/xembed_tray_linux.c @@ -0,0 +1,379 @@ +#include "xembed_tray_linux.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#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); +} diff --git a/client/ui-wails/xembed_tray_linux.h b/client/ui-wails/xembed_tray_linux.h new file mode 100644 index 000000000..1fcb151b8 --- /dev/null +++ b/client/ui-wails/xembed_tray_linux.h @@ -0,0 +1,71 @@ +#ifndef XEMBED_TRAY_H +#define XEMBED_TRAY_H + +#include + +// 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