Files
netbird/client/ui/tray_theme_watcher_linux.go
Brandon Hopkins ec0c36b0e7 [client] Add light mode with system, light, and dark theme options (#7344)
* desktop UI light mode

* Theme review fixes plus macOS window outline fix

* Windows runtime chrome re-theming plus apply serialization

* Windows chrome threading and theme event ordering fixes

* Darken toggle and setting sidebar text

* resolve theme appearance, apply on UI thread

* read theme once per window

* Re-assert Windows dark opt-in after SetTheme

* split app-wide GTK theming from per-window chrome

* Update Wails dependency and checksums

* KDE tray icon panel fix

* Five review fixes: theme ordering, cgo dedup, KDE panel resolution

* Path guard hardening, toggle contrast, windows comment

* non-vacuous escape tests

* Default view edits

* Polish settings nav, controls, borders, and disc

* Profiles settings boarder, modals, and buttons

* Additional edits based on feedback

* Switch colors away from slight blue hue

* Update missing lang

* Fix vertical tab active view
2026-09-11 08:25:10 -07:00

252 lines
6.8 KiB
Go

//go:build linux && !(linux && 386)
package main
// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE
// the kdeglobals and plasmarc files (a pinned Plasma style fixes the panel's
// appearance without touching the portal's color-scheme — see readDarkMode).
// The dark/light decision lives in tray_theme_linux.go; this file owns the
// session-bus connection and subscriptions.
import (
"path/filepath"
"sync"
"github.com/fsnotify/fsnotify"
"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
)
// themeWatcher 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 returns nil if the session bus is unavailable; callers treat
// a nil watcher as "no preference", keeping the default-dark icon.
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.
}
// The portal's signal says nothing about a pinned Plasma style.
if isKDE() {
w.watchKdeConfig()
}
log.Infof("tray theme: panel dark mode = %v", w.IsDark())
return w
}
// IsDark reports true for a nil watcher, so the icon defaults to the white
// silhouette suiting 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 whether the panel the tray icon sits on is dark.
//
// KDE goes first because a pinned Plasma style decides the panel on its own,
// independently of the application colour scheme the portal reports; with no
// style pinned that check defers to KDE's own colour files. Off KDE the
// color-scheme portal is the source; on "no preference" (0) or when unavailable
// we fall back to GTK_THEME (":dark" suffix ⇒ dark), then default to dark.
func (w *themeWatcher) readDarkMode() bool {
if dark, ok := kdePanelIsDark(); ok {
return dark
}
switch w.readColorScheme() {
case colorSchemePreferDark:
return true
case colorSchemePreferLight:
return false
default:
return gtkThemeIsDark()
}
}
// readColorScheme returns the raw freedesktop color-scheme value, 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)
}
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
}
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
}
if _, ok := sig.Body[2].(dbus.Variant); !ok {
continue
}
// Re-resolve via readDarkMode, not the signal value: under KDE a pinned
// Plasma style overrides it, so the signal value would be wrong.
w.update()
}
}
func (w *themeWatcher) update() {
dark := w.readDarkMode()
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()
}
}
// watchKdeConfig repaints on writes to either KDE file that decides the panel
// appearance. It watches their parent directory, not the files: KDE rewrites
// them atomically (write-temp + rename), which would drop an inotify watch on
// the original inode. Filtering by name re-arms implicitly.
func (w *themeWatcher) watchKdeConfig() {
path := kdeglobalsPath()
if path == "" {
return
}
dir := filepath.Dir(path)
fw, err := fsnotify.NewWatcher()
if err != nil {
log.Debugf("tray theme: KDE config watcher unavailable, theme is static: %v", err)
return
}
if err := fw.Add(dir); err != nil {
log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err)
_ = fw.Close()
return
}
go func() {
defer func() { _ = fw.Close() }()
for {
select {
case event, ok := <-fw.Events:
if !ok {
return
}
switch filepath.Base(event.Name) {
case kdeglobalsFile, plasmarcFile:
default:
continue
}
// Remove counts: deleting plasmarc unpins the Plasma style, which
// hands the decision back to the colour scheme and can flip it.
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
continue
}
w.update()
case err, ok := <-fw.Errors:
if !ok {
return
}
log.Debugf("tray theme: KDE config watch error: %v", err)
}
}
}()
}
// variantToColorScheme unwraps the color-scheme variant; the portal nests it one level.
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
}
}