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 + } +}