mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-07 01:10:03 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
client/ui-wails/tray_linux.go
Normal file
24
client/ui-wails/tray_linux.go
Normal 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.
|
||||
148
client/ui-wails/tray_watcher_linux.go
Normal file
148
client/ui-wails/tray_watcher_linux.go
Normal 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.
|
||||
}
|
||||
6
client/ui-wails/tray_watcher_other.go
Normal file
6
client/ui-wails/tray_watcher_other.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build !linux || (linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
// startStatusNotifierWatcher is a no-op on non-Linux platforms.
|
||||
func startStatusNotifierWatcher() {}
|
||||
389
client/ui-wails/xembed_host_linux.go
Normal file
389
client/ui-wails/xembed_host_linux.go
Normal 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)
|
||||
}
|
||||
18
client/ui-wails/xembed_host_other.go
Normal file
18
client/ui-wails/xembed_host_other.go
Normal 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() {}
|
||||
379
client/ui-wails/xembed_tray_linux.c
Normal file
379
client/ui-wails/xembed_tray_linux.c
Normal 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);
|
||||
}
|
||||
71
client/ui-wails/xembed_tray_linux.h
Normal file
71
client/ui-wails/xembed_tray_linux.h
Normal 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
|
||||
Reference in New Issue
Block a user