mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
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.
241 lines
7.5 KiB
Go
241 lines
7.5 KiB
Go
//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
|
|
}
|
|
}
|