diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index f3e01b36d..fe927c1d7 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -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. diff --git a/client/ui/assets/netbird-systemtray-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-connected-mono-dark.png new file mode 100644 index 000000000..1f7d40121 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-mono.png b/client/ui/assets/netbird-systemtray-connected-mono.png new file mode 100644 index 000000000..8a8710746 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono-dark.png b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png new file mode 100644 index 000000000..f208cb6bf Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono.png b/client/ui/assets/netbird-systemtray-connecting-mono.png new file mode 100644 index 000000000..e254321fd Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png new file mode 100644 index 000000000..035e71ba7 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono.png b/client/ui/assets/netbird-systemtray-disconnected-mono.png new file mode 100644 index 000000000..d68c4dc5b Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono-dark.png b/client/ui/assets/netbird-systemtray-error-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono.png b/client/ui/assets/netbird-systemtray-error-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono.png b/client/ui/assets/netbird-systemtray-needs-login-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png new file mode 100644 index 000000000..284efa880 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono.png b/client/ui/assets/netbird-systemtray-update-connected-mono.png new file mode 100644 index 000000000..ed9ceb8a2 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png new file mode 100644 index 000000000..eb0c4bcf5 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png new file mode 100644 index 000000000..519ea014a Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png differ diff --git a/client/ui/icons.go b/client/ui/icons.go index d5759cee7..1680d2d4a 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -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 diff --git a/client/ui/tray.go b/client/ui/tray.go index 4570a8303..c54376454 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -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* diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go index 0c4c864ff..cc893e2d1 100644 --- a/client/ui/tray_icon.go +++ b/client/ui/tray_icon.go @@ -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 diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go new file mode 100644 index 000000000..861d8ac98 --- /dev/null +++ b/client/ui/tray_theme_linux.go @@ -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 + } +} diff --git a/client/ui/tray_theme_other.go b/client/ui/tray_theme_other.go new file mode 100644 index 000000000..a0d20f7ab --- /dev/null +++ b/client/ui/tray_theme_other.go @@ -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() {}