From 179966b000a2b59d514d6316cec3554628ce4b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 2 Jun 2026 14:15:56 +0200 Subject: [PATCH] [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. --- client/ui/tray_theme_linux.go | 277 +++++++++---------------- client/ui/tray_theme_linux_test.go | 86 ++++++++ client/ui/tray_theme_watcher_linux.go | 280 ++++++++++++++++++++++++++ 3 files changed, 460 insertions(+), 183 deletions(-) create mode 100644 client/ui/tray_theme_linux_test.go create mode 100644 client/ui/tray_theme_watcher_linux.go diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go index 861d8ac98..fccc32fcb 100644 --- a/client/ui/tray_theme_linux.go +++ b/client/ui/tray_theme_linux.go @@ -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 - } -} diff --git a/client/ui/tray_theme_linux_test.go b/client/ui/tray_theme_linux_test.go new file mode 100644 index 000000000..f14f08d7f --- /dev/null +++ b/client/ui/tray_theme_linux_test.go @@ -0,0 +1,86 @@ +//go:build linux && !(linux && 386) + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadKdeComplementaryBackground(t *testing.T) { + // Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark. + // The tray sits on the panel, which Plasma paints from Complementary, so + // the panel is dark even though the global color-scheme is Light. + content := `[Colors:Window] +BackgroundNormal=239,240,241 + +[Colors:Complementary] +BackgroundAlternate=27,30,32 +BackgroundNormal=42,46,50 + +[General] +ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4 +` + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + rgb, ok := readKdeComplementaryBackground(path) + if !ok { + t.Fatal("expected to find Complementary BackgroundNormal") + } + if rgb != [3]uint8{42, 46, 50} { + t.Fatalf("rgb = %v, want [42 46 50]", rgb) + } + if !isDarkRGB(rgb[0], rgb[1], rgb[2]) { + t.Fatal("panel colour 42,46,50 should be dark") + } + // The Window background (what color-scheme reflects) is light — the bug + // this fix addresses is picking the icon from that instead of the panel. + if isDarkRGB(239, 240, 241) { + t.Fatal("window colour 239,240,241 should be light") + } +} + +func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) { + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readKdeComplementaryBackground(path); ok { + t.Fatal("expected not-ok when Complementary group is absent") + } +} + +func TestParseRGB(t *testing.T) { + if _, ok := parseRGB("1,2"); ok { + t.Fatal("two components should fail") + } + if _, ok := parseRGB("300,0,0"); ok { + t.Fatal("out-of-range should fail") + } + if _, ok := parseRGB("a,b,c"); ok { + t.Fatal("non-numeric should fail") + } + rgb, ok := parseRGB(" 10 , 20 , 30 ") + if !ok || rgb != [3]uint8{10, 20, 30} { + t.Fatalf("parseRGB = %v ok=%v, want [10 20 30] true", rgb, ok) + } +} + +func TestIsDarkRGB(t *testing.T) { + if !isDarkRGB(0, 0, 0) { + t.Fatal("black is dark") + } + if isDarkRGB(255, 255, 255) { + t.Fatal("white is light") + } + if !isDarkRGB(42, 46, 50) { + t.Fatal("Breeze panel grey is dark") + } + if isDarkRGB(239, 240, 241) { + t.Fatal("Breeze window grey is light") + } +} diff --git a/client/ui/tray_theme_watcher_linux.go b/client/ui/tray_theme_watcher_linux.go new file mode 100644 index 000000000..b12c4930a --- /dev/null +++ b/client/ui/tray_theme_watcher_linux.go @@ -0,0 +1,280 @@ +//go:build linux && !(linux && 386) + +package main + +// themeWatcher: the live half of Linux panel-theme detection. It seeds the +// current dark/light state, then watches for changes from two sources and +// repaints the tray icon when the panel theme flips: +// - the freedesktop Settings portal's SettingChanged signal (the cross- +// desktop colour-scheme source), and +// - on KDE, the user kdeglobals file (the portal's color-scheme doesn't +// track the panel's Complementary colour — see readDarkMode). +// +// The dark/light decision itself lives in tray_theme_linux.go; this file owns +// the session-bus connection, the signal/file subscriptions, and the repaint. + +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 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. + } + + // On KDE the portal's color-scheme signal doesn't track the panel's + // Complementary colour, so watch kdeglobals directly to repaint on a + // theme switch. + if isKDE() { + w.watchKdeglobals() + } + + 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 whether the desktop panel (where the tray icon sits) +// is dark. +// +// On KDE the freedesktop color-scheme is the *application* window preference, +// not the panel's: Plasma paints its panel and system tray from the Breeze +// "Complementary" colour group, which stays dark even under a Light global +// scheme (kdeglobals [Colors:Window] light vs [Colors:Complementary] dark). +// So a light color-scheme there would wrongly pick the black silhouette, +// which then disappears against the dark panel. We therefore read the actual +// panel background from kdeglobals first under KDE and decide by its luma. +// +// Off KDE (or when kdeglobals can't be read), the freedesktop color-scheme +// portal is the 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 nothing yields a +// signal we default to dark, matching the common dark Linux panel. +func (w *themeWatcher) readDarkMode() bool { + if dark, ok := kdePanelIsDark(); ok { + return dark + } + 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) +} + +// 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 + } + if _, ok := sig.Body[2].(dbus.Variant); !ok { + continue + } + + // Re-resolve via readDarkMode rather than the signal's value: under + // KDE the panel colour comes from kdeglobals' Complementary group, + // not the portal's color-scheme, so the signal value alone would be + // wrong there. Off KDE this just re-reads the same color-scheme. + w.update() + } +} + +// update re-resolves the panel dark/light state and repaints the icon if it +// flipped. Shared by the portal-signal loop and the KDE kdeglobals watcher. +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() + } +} + +// watchKdeglobals watches the user kdeglobals file for changes and re-resolves +// the panel theme on each write, so a KDE colour-scheme switch repaints the +// icon live. KDE rewrites kdeglobals atomically (write-temp + rename), which +// drops the inotify watch on the original inode, so we watch the parent +// directory and filter to the kdeglobals name, re-arming implicitly. +func (w *themeWatcher) watchKdeglobals() { + path := kdeglobalsPath() + if path == "" { + return + } + dir, name := filepath.Split(path) + + fw, err := fsnotify.NewWatcher() + if err != nil { + log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err) + return + } + if err := fw.Add(filepath.Clean(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 + } + if filepath.Base(event.Name) != name { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + w.update() + case err, ok := <-fw.Errors: + if !ok { + return + } + log.Debugf("tray theme: kdeglobals watch error: %v", err) + } + } + }() +} + +// 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 + } +}