mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-08 17:59:56 +00:00
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.
380 lines
12 KiB
C
380 lines
12 KiB
C
#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);
|
|
}
|