mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 22:29:08 +02:00
[client/ui] Detect KDE panel dark mode from kdeglobals Complementary colour
Split Linux panel-theme detection into two files and fix the KDE case
where the tray icon picked the wrong mono variant.
The freedesktop Settings portal's color-scheme reports the *global*
light/dark preference, but the KDE panel is painted from the
Complementary colour group, which can be dark even when the global
scheme is Light. The tray sits on the panel, so keying its black/white
mono icon off the portal value alone gave the wrong contrast on KDE.
Changes:
- tray_theme_linux.go keeps the dark/light decision; on KDE it now
reads the user's kdeglobals [Colors:Complementary] BackgroundNormal
to determine the actual panel luminance, falling back to the portal
color-scheme / GTK_THEME chain elsewhere.
- tray_theme_watcher_linux.go (new) owns the live half: a private
session-bus connection for the portal SettingChanged signal plus an
fsnotify watch on kdeglobals, repainting the tray on a panel-theme
flip.
- tray_theme_linux_test.go (new) covers the kdeglobals Complementary
parse against the KDE test-VM's real file layout.
This commit is contained in:
+94
-183
@@ -8,37 +8,26 @@ package main
|
||||
// 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.
|
||||
// detect the desktop's colour scheme ourselves and pick the black or white
|
||||
// silhouette in iconForState.
|
||||
//
|
||||
// This file holds the (stateless) dark/light decision helpers; the live
|
||||
// watcher that seeds and repaints on change lives in
|
||||
// tray_theme_watcher_linux.go.
|
||||
//
|
||||
// color-scheme values (per the freedesktop appearance spec):
|
||||
// 0 = no preference, 1 = prefer dark, 2 = prefer light.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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
|
||||
@@ -48,97 +37,106 @@ func (t *Tray) startTrayTheme() {
|
||||
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
|
||||
// isKDE reports whether the current desktop is KDE Plasma. XDG_CURRENT_DESKTOP
|
||||
// is a colon-separated list (e.g. "KDE", "ubuntu:KDE"), so we match the token.
|
||||
func isKDE() bool {
|
||||
for _, d := range strings.Split(os.Getenv("XDG_CURRENT_DESKTOP"), ":") {
|
||||
if strings.EqualFold(strings.TrimSpace(d), "KDE") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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()
|
||||
// kdeglobalsPath returns the user kdeglobals path ($XDG_CONFIG_HOME/kdeglobals,
|
||||
// or ~/.config/kdeglobals), the highest-priority file in KDE's config cascade.
|
||||
// We read only this file rather than replaying the full XDG_CONFIG_DIRS +
|
||||
// kdedefaults cascade: the user file is where Plasma writes the active scheme,
|
||||
// and if the Complementary group is absent here we fall back to the portal.
|
||||
func kdeglobalsPath() string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return filepath.Join(dir, "kdeglobals")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err)
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
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
|
||||
return filepath.Join(home, ".config", "kdeglobals")
|
||||
}
|
||||
|
||||
// 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
|
||||
// kdePanelIsDark reports whether the KDE Plasma panel is dark, reading the
|
||||
// Breeze "Complementary" background — the colour Plasma actually paints the
|
||||
// panel/system-tray with — from kdeglobals and deciding by its luma. The
|
||||
// second return is false when this isn't KDE or the colour can't be read, so
|
||||
// readDarkMode falls through to the portal/GTK path.
|
||||
func kdePanelIsDark() (dark, ok bool) {
|
||||
if !isKDE() {
|
||||
return false, false
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.darkMode
|
||||
path := kdeglobalsPath()
|
||||
if path == "" {
|
||||
return false, false
|
||||
}
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
|
||||
}
|
||||
|
||||
// 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()
|
||||
// readKdeComplementaryBackground parses kdeglobals for
|
||||
// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255).
|
||||
func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err)
|
||||
return rgb, false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
const group = "[Colors:Complementary]"
|
||||
inGroup := false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inGroup = line == group
|
||||
continue
|
||||
}
|
||||
if !inGroup {
|
||||
continue
|
||||
}
|
||||
key, val, found := strings.Cut(line, "=")
|
||||
if !found || strings.TrimSpace(key) != "BackgroundNormal" {
|
||||
continue
|
||||
}
|
||||
return parseRGB(strings.TrimSpace(val))
|
||||
}
|
||||
return rgb, false
|
||||
}
|
||||
|
||||
// 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
|
||||
// parseRGB parses a "r,g,b" triple (KDE's colour format) into bytes.
|
||||
func parseRGB(s string) (rgb [3]uint8, ok bool) {
|
||||
parts := strings.Split(s, ",")
|
||||
if len(parts) != 3 {
|
||||
return rgb, false
|
||||
}
|
||||
|
||||
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
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err != nil || n < 0 || n > 255 {
|
||||
return rgb, false
|
||||
}
|
||||
rgb[i] = uint8(n)
|
||||
}
|
||||
return rgb, true
|
||||
}
|
||||
|
||||
return variantToColorScheme(v)
|
||||
// isDarkRGB reports whether a colour is dark using the Rec. 601 relative luma.
|
||||
// The 128 midpoint matches the perceptual split between needing a light vs a
|
||||
// dark foreground.
|
||||
func isDarkRGB(r, g, b uint8) bool {
|
||||
luma := (299*int(r) + 587*int(g) + 114*int(b)) / 1000
|
||||
return luma < 128
|
||||
}
|
||||
|
||||
// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is
|
||||
@@ -151,90 +149,3 @@ func gtkThemeIsDark() bool {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user