client/ui: use monochrome tray icons on Linux

Linux now shows monochrome (black/white silhouette) tray icons instead
of the colored orange PNGs, matching the macOS template look. Since
Wails' Linux SNI backend ignores SetDarkModeIcon (its setDarkModeIcon
just calls setIcon, last-write-wins) and the SNI spec carries no panel
light/dark hint, the panel color scheme is detected in-process and the
black-vs-white silhouette is chosen in iconForState, pushed via a single
SetIcon.

Detection order (tray_theme_linux.go): freedesktop Settings portal
(org.freedesktop.appearance/color-scheme) -> GTK_THEME env (:dark
suffix) -> default dark. A SettingChanged subscription repaints live on
theme flips. macOS (template) and Windows (colored) paths are unchanged.

Icons are 48x48 mono PNGs (3% margin) generated from the macOS
silhouettes.
This commit is contained in:
Zoltán Papp
2026-06-01 20:23:46 +02:00
parent 5bebecc427
commit 4cee07bef5
20 changed files with 364 additions and 1 deletions

View File

@@ -13,7 +13,8 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`.
- `signal_unix.go` / `signal_windows.go``listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows.
- `icons.go``//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux ships light + dark PNGs; Windows reuses the light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`).
- `icons.go``//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux uses a monochrome black/white pair (`*-mono.png` black for light panels, `*-mono-dark.png` white for dark panels); Windows reuses the colored light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`). The `*-mono*` set is generated from the macOS template silhouettes (states differ by shape, not color); `tray_icon.go iconForState` branches on `runtime.GOOS` (`linux` → mono, else colored).
- **Linux mono icon theme selection** — Wails v3's Linux SNI backend ignores `SetDarkModeIcon` (its `setDarkModeIcon` just calls `setIcon`, last-write-wins — see `pkg/application/systemtray_linux.go`), and the SNI spec carries no panel light/dark hint. So `tray_theme_linux.go` detects the desktop colour scheme itself and `iconForState` picks black-vs-white, with `applyIcon` pushing a single `SetIcon` on Linux (no `SetDarkModeIcon`). Detection order: freedesktop **Settings portal** (`org.freedesktop.portal.Settings.Read` of `org.freedesktop.appearance`/`color-scheme`: 0=no-pref, 1=dark, 2=light) → on 0/unavailable, fall back to the **`GTK_THEME`** env var (`:dark` suffix ⇒ dark) → else default dark (suits the common dark panel). A private session-bus `SettingChanged` subscription repaints live on theme flips. `Tray.panelDark func() bool` is seeded by `startTrayTheme()` (Linux only; `tray_theme_other.go` is a no-op stub) before the first `applyIcon`; `panelIsDark()` returns true when `panelDark` is nil.
### Wails services (`services/*.go`)
Each service is registered via `app.RegisterService(application.NewService(svc))`. Every method becomes a TS function in `frontend/bindings/.../services/`. Frontend-facing details (TS signatures, push events, models) are in `frontend/WAILS-API.md`. After editing any `services/*.go` or the proto, regenerate with `wails3 generate bindings -clean=true -ts` (or `pnpm bindings` from `frontend/`). `frontend/bindings/**` is gitignored.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -56,6 +56,55 @@ var iconUpdateConnectedMacOS []byte
//go:embed assets/netbird-systemtray-update-disconnected-macos.png
var iconUpdateDisconnectedMacOS []byte
// Linux monochrome tray icons. Linux's SNI tray has no template-recoloring
// (unlike macOS's SetTemplateIcon), so we ship an explicit black/white pair:
// the black silhouette (*-mono.png) goes to SetIcon for light panels, the
// white one (*-mono-dark.png) goes to SetDarkModeIcon for dark panels, and
// the SNI host picks per panel theme. Generated from the macOS template
// silhouettes — states differ by shape, not color.
//go:embed assets/netbird-systemtray-connected-mono.png
var iconConnectedMono []byte
//go:embed assets/netbird-systemtray-connected-mono-dark.png
var iconConnectedMonoDark []byte
//go:embed assets/netbird-systemtray-connecting-mono.png
var iconConnectingMono []byte
//go:embed assets/netbird-systemtray-connecting-mono-dark.png
var iconConnectingMonoDark []byte
//go:embed assets/netbird-systemtray-disconnected-mono.png
var iconDisconnectedMono []byte
//go:embed assets/netbird-systemtray-disconnected-mono-dark.png
var iconDisconnectedMonoDark []byte
//go:embed assets/netbird-systemtray-error-mono.png
var iconErrorMono []byte
//go:embed assets/netbird-systemtray-error-mono-dark.png
var iconErrorMonoDark []byte
//go:embed assets/netbird-systemtray-needs-login-mono.png
var iconNeedsLoginMono []byte
//go:embed assets/netbird-systemtray-needs-login-mono-dark.png
var iconNeedsLoginMonoDark []byte
//go:embed assets/netbird-systemtray-update-connected-mono.png
var iconUpdateConnectedMono []byte
//go:embed assets/netbird-systemtray-update-connected-mono-dark.png
var iconUpdateConnectedMonoDark []byte
//go:embed assets/netbird-systemtray-update-disconnected-mono.png
var iconUpdateDisconnectedMono []byte
//go:embed assets/netbird-systemtray-update-disconnected-mono-dark.png
var iconUpdateDisconnectedMonoDark []byte
//go:embed assets/netbird.png
var iconWindow []byte

View File

@@ -76,6 +76,12 @@ type Tray struct {
tray *application.SystemTray
window *application.WebviewWindow
svc TrayServices
// panelDark reports whether the desktop panel uses a dark colour
// scheme, so iconForState can pick the black vs white monochrome tray
// icon on Linux. Set by startTrayTheme (Linux only); nil on macOS and
// Windows, where the OS/Wails handles light-vs-dark icon selection and
// panelIsDark falls back to its default.
panelDark func() bool
// loc owns the active language plus the preference subscription. The
// tray talks to it for every translated label (t.loc.T(...)) and
// registers a callback in NewTray that re-renders the menu on a
@@ -192,6 +198,10 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection (Linux only) before the first paint so the
// initial icon already matches the panel's light/dark scheme; repaints
// on live theme switches.
t.startTrayTheme()
t.applyIcon()
t.tray.SetTooltip(t.loc.T("tray.tooltip"))
// On Linux the SNI hover tooltip is sourced from the systray *Label*

View File

@@ -29,12 +29,32 @@ func (t *Tray) applyIcon() {
t.tray.SetTemplateIcon(icon)
return
}
if runtime.GOOS == "linux" {
// Wails' Linux SNI backend ignores SetDarkModeIcon (its
// setDarkModeIcon just calls setIcon, last-write-wins), so we pick
// the black-vs-white silhouette ourselves in iconForState based on
// the panel theme and push a single SetIcon. Calling
// SetDarkModeIcon here would only clobber that choice.
t.tray.SetIcon(icon)
return
}
t.tray.SetIcon(icon)
if dark != nil {
t.tray.SetDarkModeIcon(dark)
}
}
// panelIsDark reports whether the desktop panel uses a dark colour scheme, so
// the Linux branch of iconForState can choose the white silhouette. Defaults
// to true when no detector is wired (panelDark nil — non-Linux, or the
// freedesktop portal was unavailable), matching the common dark Linux panel.
func (t *Tray) panelIsDark() bool {
if t.panelDark == nil {
return true
}
return t.panelDark()
}
func (t *Tray) iconForState() (icon, dark []byte) {
t.statusMu.Lock()
connected := t.connected
@@ -71,6 +91,38 @@ func (t *Tray) iconForState() (icon, dark []byte) {
}
}
if runtime.GOOS == "linux" {
// Linux: monochrome silhouette chosen by panel theme. Wails' SNI
// backend can't switch icons per theme itself (see applyIcon), so we
// resolve black (light panel) vs white (dark panel) here and the
// caller pushes a single SetIcon. The second return is unused on
// Linux.
dark := t.panelIsDark()
pick := func(black, white []byte) ([]byte, []byte) {
if dark {
return white, nil
}
return black, nil
}
switch {
case connecting:
return pick(iconConnectingMono, iconConnectingMonoDark)
case errored:
return pick(iconErrorMono, iconErrorMonoDark)
case needsLogin:
return pick(iconNeedsLoginMono, iconNeedsLoginMonoDark)
case connected && hasUpdate:
return pick(iconUpdateConnectedMono, iconUpdateConnectedMonoDark)
case connected:
return pick(iconConnectedMono, iconConnectedMonoDark)
case hasUpdate:
return pick(iconUpdateDisconnectedMono, iconUpdateDisconnectedMonoDark)
default:
return pick(iconDisconnectedMono, iconDisconnectedMonoDark)
}
}
// Windows: colored PNGs.
switch {
case connecting:
return iconConnecting, nil

View File

@@ -0,0 +1,240 @@
//go:build linux && !(linux && 386)
package main
// Linux panel-theme detection for the monochrome tray icons.
//
// Wails v3's Linux SNI backend does not honour SetDarkModeIcon — its
// setDarkModeIcon just calls setIcon, so the last write wins regardless of
// panel theme (see pkg/application/systemtray_linux.go). The SNI spec itself
// also carries no reliable "panel is dark/light" hint for clients. So we
// detect the desktop's colour-scheme preference ourselves via the
// freedesktop Settings portal (org.freedesktop.portal.Settings, the
// org.freedesktop.appearance/color-scheme key) and pick the black or white
// silhouette in iconForState. We also subscribe to the portal's
// SettingChanged signal so a live theme switch repaints the icon.
//
// color-scheme values (per the freedesktop appearance spec):
// 0 = no preference, 1 = prefer dark, 2 = prefer light.
import (
"os"
"strings"
"sync"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
const (
portalBusName = "org.freedesktop.portal.Desktop"
portalObjectPath = "/org/freedesktop/portal/desktop"
portalSettings = "org.freedesktop.portal.Settings"
appearanceNamespace = "org.freedesktop.appearance"
colorSchemeKey = "color-scheme"
colorSchemeNoPreference = 0
colorSchemePreferDark = 1
colorSchemePreferLight = 2
)
// startTrayTheme wires the Linux panel-theme watcher into the tray: it seeds
// t.panelDark from the freedesktop Settings portal and repaints the icon on
// every live colour-scheme flip. Called from NewTray before the first
// applyIcon so the initial paint already uses the right silhouette.
func (t *Tray) startTrayTheme() {
w := startThemeWatcher(func() { t.applyIcon() })
t.panelDark = w.IsDark
}
// themeWatcher reads the desktop colour-scheme preference over the session
// bus and invokes onChange whenever it flips. It owns a private session-bus
// connection so its signal subscription is isolated from the SNI watcher's.
type themeWatcher struct {
conn *dbus.Conn
onChange func()
mu sync.Mutex
darkMode bool
}
// startThemeWatcher opens a private session-bus connection, seeds the current
// colour scheme, and subscribes to the portal's SettingChanged signal. It
// returns nil (and logs) if the portal is unavailable — callers treat a nil
// watcher as "no preference", which keeps the default-dark icon choice.
func startThemeWatcher(onChange func()) *themeWatcher {
conn, err := dbus.SessionBusPrivate()
if err != nil {
log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err)
return nil
}
if err := conn.Auth(nil); err != nil {
_ = conn.Close()
log.Debugf("tray theme: dbus auth failed: %v", err)
return nil
}
if err := conn.Hello(); err != nil {
_ = conn.Close()
log.Debugf("tray theme: dbus hello failed: %v", err)
return nil
}
w := &themeWatcher{conn: conn, onChange: onChange}
w.darkMode = w.readDarkMode()
if err := w.subscribe(); err != nil {
log.Debugf("tray theme: SettingChanged subscription failed, theme is static: %v", err)
// Keep the connection: the seeded darkMode value is still useful.
}
log.Infof("tray theme: panel dark mode = %v", w.IsDark())
return w
}
// IsDark reports the last observed colour-scheme preference. A nil watcher
// (portal unavailable) reports true so the icon defaults to the white
// silhouette, which suits the common dark Linux panel.
func (w *themeWatcher) IsDark() bool {
if w == nil {
return true
}
w.mu.Lock()
defer w.mu.Unlock()
return w.darkMode
}
// readDarkMode resolves the current dark/light preference. The freedesktop
// color-scheme portal is the primary source; when it is unavailable or
// reports "no preference" (0), we fall back to the GTK_THEME env var (the
// GTK convention appends ":dark" for the dark variant, e.g. "Adwaita:dark").
// If neither yields a signal we default to dark, matching the common dark
// Linux panel.
func (w *themeWatcher) readDarkMode() bool {
switch w.readColorScheme() {
case colorSchemePreferDark:
return true
case colorSchemePreferLight:
return false
default: // colorSchemeNoPreference or portal unavailable
return gtkThemeIsDark()
}
}
// readColorScheme returns the raw freedesktop color-scheme value (0 = no
// preference, 1 = prefer dark, 2 = prefer light), or colorSchemeNoPreference
// when the portal can't be reached.
func (w *themeWatcher) readColorScheme() uint32 {
obj := w.conn.Object(portalBusName, portalObjectPath)
call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey)
if call.Err != nil {
log.Debugf("tray theme: portal Read failed, falling back to GTK_THEME: %v", call.Err)
return colorSchemeNoPreference
}
var v dbus.Variant
if err := call.Store(&v); err != nil {
log.Debugf("tray theme: portal Read decode failed, falling back to GTK_THEME: %v", err)
return colorSchemeNoPreference
}
return variantToColorScheme(v)
}
// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is
// treated as dark to match the default-dark fallback used elsewhere.
func gtkThemeIsDark() bool {
theme := os.Getenv("GTK_THEME")
if theme == "" {
return true
}
// GTK_THEME is "Name[:variant]"; the dark variant is ":dark".
return strings.Contains(strings.ToLower(theme), ":dark")
}
// subscribe registers a match rule for the portal's SettingChanged signal and
// spawns a goroutine that re-reads the scheme and fires onChange on each
// relevant change.
func (w *themeWatcher) subscribe() error {
if err := w.conn.AddMatchSignal(
dbus.WithMatchObjectPath(portalObjectPath),
dbus.WithMatchInterface(portalSettings),
dbus.WithMatchMember("SettingChanged"),
); err != nil {
return err
}
sigs := make(chan *dbus.Signal, 8)
w.conn.Signal(sigs)
go w.loop(sigs)
return nil
}
// loop consumes SettingChanged signals, filters to the colour-scheme key, and
// repaints the icon when the dark/light preference actually flips.
func (w *themeWatcher) loop(sigs chan *dbus.Signal) {
for sig := range sigs {
if sig.Name != portalSettings+".SettingChanged" {
continue
}
// Signal body: (namespace string, key string, value variant).
if len(sig.Body) < 3 {
continue
}
namespace, _ := sig.Body[0].(string)
key, _ := sig.Body[1].(string)
if namespace != appearanceNamespace || key != colorSchemeKey {
continue
}
variant, ok := sig.Body[2].(dbus.Variant)
if !ok {
continue
}
dark := colorSchemeToDark(variantToColorScheme(variant))
w.mu.Lock()
changed := dark != w.darkMode
w.darkMode = dark
w.mu.Unlock()
if changed && w.onChange != nil {
log.Infof("tray theme: panel dark mode changed to %v", dark)
w.onChange()
}
}
}
// colorSchemeToDark maps a freedesktop color-scheme value to a dark/light
// bool, deferring "no preference" (0) to the GTK_THEME fallback.
func colorSchemeToDark(scheme uint32) bool {
switch scheme {
case colorSchemePreferDark:
return true
case colorSchemePreferLight:
return false
default:
return gtkThemeIsDark()
}
}
// variantToColorScheme unwraps the color-scheme variant (the portal nests it
// one level: a variant holding a uint32) into the raw scheme value, returning
// colorSchemeNoPreference for an unexpected payload.
func variantToColorScheme(v dbus.Variant) uint32 {
inner := v.Value()
if nested, ok := inner.(dbus.Variant); ok {
inner = nested.Value()
}
switch n := inner.(type) {
case uint32:
return n
case int32:
return uint32(n)
case uint8:
return uint32(n)
default:
log.Debugf("tray theme: unexpected color-scheme type %T, assuming no preference", inner)
return colorSchemeNoPreference
}
}

View File

@@ -0,0 +1,11 @@
//go:build !linux || (linux && 386)
package main
// startTrayTheme is a no-op off Linux: macOS uses template icons (the OS
// recolours them per menubar appearance) and Windows ships colored PNGs, so
// neither needs the freedesktop colour-scheme probe that the Linux build
// uses to choose between the black and white monochrome silhouettes. Left
// callable so NewTray can invoke it unconditionally; panelDark stays nil and
// panelIsDark returns its default.
func (t *Tray) startTrayTheme() {}