Files
netbird/client/ui/tray_theme_linux_test.go
T
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

384 lines
12 KiB
Go

//go:build linux && !(linux && 386)
package main
import (
"os"
"path/filepath"
"testing"
)
// Values measured on Plasma 6.7.4 (Fedora 44) under each Breeze scheme. The
// Complementary group is dark under both, which is why it can't decide the
// panel; Window tracks it.
const (
kdeglobalsLight = `[Colors:Complementary]
BackgroundAlternate=27,30,32
BackgroundNormal=42,46,50
[Colors:Window]
BackgroundNormal=239,240,241
[General]
ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4
`
kdeglobalsDark = `[Colors:Complementary]
BackgroundNormal=32,35,38
[Colors:Window]
BackgroundNormal=32,35,38
[General]
ColorScheme=BreezeDark
`
)
// plasmaStyle installs a Plasma style of that name under XDG_DATA_HOME. An
// empty colours body installs the style without a colours file, which is what
// the stock "default" style looks like.
func plasmaStyle(t *testing.T, name, colours string) string {
t.Helper()
data := t.TempDir()
dir := filepath.Join(data, "plasma", "desktoptheme", name)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if colours != "" {
if err := os.WriteFile(filepath.Join(dir, "colors"), []byte(colours), 0o600); err != nil {
t.Fatal(err)
}
}
t.Setenv("XDG_DATA_HOME", data)
// Keep the system dirs out of it so an installed breeze-dark cannot answer.
t.Setenv("XDG_DATA_DIRS", filepath.Join(data, "empty"))
return data
}
// kdeConfig points the KDE readers at a temp dir holding the given files, and
// makes isKDE report KDE. An empty body skips the file.
func kdeConfig(t *testing.T, kdeglobals, plasmarc string) {
t.Helper()
dir := t.TempDir()
for name, body := range map[string]string{kdeglobalsFile: kdeglobals, plasmarcFile: plasmarc} {
if body == "" {
continue
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CURRENT_DESKTOP", "KDE")
// Point the Plasma style lookup at empty dirs so a style installed on the
// host cannot answer for a test that did not install one itself. Tests that
// want a style call plasmaStyle, which overrides these.
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "empty-data-home"))
t.Setenv("XDG_DATA_DIRS", filepath.Join(dir, "empty-data-dirs"))
}
// The reported bug: a Light global scheme left the panel reported dark, so the
// tray kept the white silhouette on a light panel.
func TestKdePanelIsDarkFollowsColourScheme(t *testing.T) {
for _, tc := range []struct {
name string
kdeglobals string
wantDark bool
}{
{"light scheme", kdeglobalsLight, false},
{"dark scheme", kdeglobalsDark, true},
} {
t.Run(tc.name, func(t *testing.T) {
kdeConfig(t, tc.kdeglobals, "")
dark, ok := kdePanelIsDark()
if !ok {
t.Fatal("expected a conclusive answer from kdeglobals")
}
if dark != tc.wantDark {
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
}
})
}
}
// A pinned Plasma style paints the panel regardless of the colour scheme, so it
// has to outrank it in both directions. The styles are installed into the test's
// own XDG_DATA_HOME: reading whatever the host happens to ship would make the
// result depend on the machine.
func TestKdePanelIsDarkPinnedStyleOutranksScheme(t *testing.T) {
for _, tc := range []struct {
name string
kdeglobals string
style string
styleColours string
wantDark bool
}{
{"dark style, light scheme", kdeglobalsLight, "breeze-dark", kdeglobalsDark, true},
{"light style, dark scheme", kdeglobalsDark, "breeze-light", kdeglobalsLight, false},
} {
t.Run(tc.name, func(t *testing.T) {
kdeConfig(t, tc.kdeglobals, "[Theme]\nname="+tc.style+"\n")
plasmaStyle(t, tc.style, tc.styleColours)
dark, ok := kdePanelIsDark()
if !ok {
t.Fatal("expected a conclusive answer from the pinned style")
}
if dark != tc.wantDark {
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
}
})
}
}
// "default" fixes nothing, so the colour scheme still decides.
func TestKdePanelIsDarkDefaultStyleDefersToScheme(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
dark, ok := kdePanelIsDark()
if !ok {
t.Fatal("expected the colour scheme to answer")
}
if dark {
t.Fatal("default style on a Light scheme is a light panel")
}
}
// No colours and no style: stay inconclusive so readDarkMode uses the portal
// rather than guessing.
func TestKdePanelIsDarkInconclusive(t *testing.T) {
t.Run("no window group", func(t *testing.T) {
kdeConfig(t, "[Colors:Complementary]\nBackgroundNormal=42,46,50\n", "")
if _, ok := kdePanelIsDark(); ok {
t.Fatal("expected not-ok without a Window group")
}
})
t.Run("no kde files", func(t *testing.T) {
kdeConfig(t, "", "")
if _, ok := kdePanelIsDark(); ok {
t.Fatal("expected not-ok with no kdeglobals at all")
}
})
t.Run("not kde", func(t *testing.T) {
kdeConfig(t, kdeglobalsDark, "")
t.Setenv("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")
if _, ok := kdePanelIsDark(); ok {
t.Fatal("expected not-ok off KDE")
}
})
}
// The panel follows the pinned style's own colours, not its name. Reproduces
// the measured case: a neutrally named style shipping dark colours while
// kdeglobals reports light.
func TestPlasmaStyleIsDarkUsesStyleColours(t *testing.T) {
for _, tc := range []struct {
name string
style string
colours string
wantDark bool
}{
{"neutral name, dark colours", "nbtestneutral", kdeglobalsDark, true},
{"neutral name, light colours", "nbtestneutral", kdeglobalsLight, false},
{"name says dark, colours are light", "midnight-dark", kdeglobalsLight, false},
{"name says light, colours are dark", "daylight", kdeglobalsDark, true},
} {
t.Run(tc.name, func(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.style+"\n")
plasmaStyle(t, tc.style, tc.colours)
dark, ok := plasmaStyleIsDark()
if !ok {
t.Fatal("a style shipping colours should be conclusive")
}
if dark != tc.wantDark {
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
}
})
}
}
// End to end through kdePanelIsDark: the style's colours must beat kdeglobals.
func TestKdePanelIsDarkStyleColoursBeatColourScheme(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=nbtestneutral\n")
plasmaStyle(t, "nbtestneutral", kdeglobalsDark)
dark, ok := kdePanelIsDark()
if !ok {
t.Fatal("expected a conclusive answer")
}
if !dark {
t.Fatal("a dark-coloured style on a Light scheme is a dark panel")
}
}
// A style with no colours file is the "default" case: it follows the scheme.
func TestPlasmaStyleIsDarkNoColoursFile(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
plasmaStyle(t, "default", "")
if _, ok := plasmaStyleIsDark(); ok {
t.Fatal("a style without colours must defer to the colour scheme")
}
// and the whole resolution then lands on the light scheme
dark, ok := kdePanelIsDark()
if !ok || dark {
t.Fatalf("kdePanelIsDark() = (%v, %v), want (false, true)", dark, ok)
}
}
func TestPlasmaStyleIsDarkInconclusive(t *testing.T) {
t.Run("no plasmarc", func(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "")
plasmaStyle(t, "unused", kdeglobalsDark)
if _, ok := plasmaStyleIsDark(); ok {
t.Fatal("expected not-ok with no plasmarc")
}
})
t.Run("empty style name", func(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=\n")
if _, ok := plasmaStyleIsDark(); ok {
t.Fatal("expected not-ok for an empty style name")
}
})
t.Run("style not installed", func(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=absent\n")
plasmaStyle(t, "somethingelse", kdeglobalsDark)
if _, ok := plasmaStyleIsDark(); ok {
t.Fatal("expected not-ok when the style is not installed")
}
})
// ... but a leading dot in an ordinary name is fine.
for _, good := range []string{".hidden", "..."} {
t.Run("accepts "+good, func(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+good+"\n")
plasmaStyle(t, good, kdeglobalsDark)
dark, ok := plasmaStyleIsDark()
if !ok || !dark {
t.Fatalf("plasmaStyleIsDark() = (%v, %v) for %q, want (true, true)", dark, ok, good)
}
})
}
}
// A traversing name must be rejected outright, not merely fail to find a file.
// Each case plants colours at exactly the path the unguarded lookup would read,
// so removing the name check makes plasmaStyleIsDark answer from the planted
// file and these fail. Without the planted file the test would pass either way.
func TestPlasmaStyleIsDarkRejectsPlantedEscape(t *testing.T) {
for _, tc := range []struct {
name string
// where filepath.Join(data, "plasma", "desktoptheme", name) lands
escaped []string
}{
{"..", []string{"plasma"}},
{".", []string{"plasma", "desktoptheme"}},
{"/", []string{"plasma", "desktoptheme"}},
} {
t.Run(tc.name, func(t *testing.T) {
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.name+"\n")
data := plasmaStyle(t, "unused", kdeglobalsLight)
target := filepath.Join(append([]string{data}, tc.escaped...)...)
// Guard the fixture itself: if Join ever stops landing here the
// test would go quietly vacuous again.
want := filepath.Clean(filepath.Join(data, "plasma", "desktoptheme", tc.name))
if target != want {
t.Fatalf("fixture targets %q but the lookup resolves %q", target, want)
}
if err := os.WriteFile(filepath.Join(target, "colors"), []byte(kdeglobalsDark), 0o600); err != nil {
t.Fatal(err)
}
if dark, ok := plasmaStyleIsDark(); ok {
t.Fatalf("plasmaStyleIsDark() = (%v, true) for %q: the name must be rejected, "+
"not resolved against %s", dark, tc.name, target)
}
})
}
}
// The rejection condition on its own, so every case is checked whether or not a
// file happens to exist at the path it would resolve to.
func TestIsBareStyleName(t *testing.T) {
for _, bad := range []string{"", ".", "..", "/", "//", "/etc", "../../../../etc", "a/b", "a/", "./x", "../x"} {
if isBareStyleName(bad) {
t.Errorf("isBareStyleName(%q) = true, want false", bad)
}
}
for _, good := range []string{"breeze-dark", "default", ".hidden", "...", "Breeze Dark", "a.b"} {
if !isBareStyleName(good) {
t.Errorf("isBareStyleName(%q) = false, want true", good)
}
}
}
// A local style of the same name shadows the system one, as in Plasma.
func TestPlasmaStyleDirsPreferUserData(t *testing.T) {
t.Setenv("XDG_DATA_HOME", "/home/someone/.local/share")
t.Setenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
got := plasmaStyleDirs("breeze-dark")
want := []string{
"/home/someone/.local/share/plasma/desktoptheme/breeze-dark",
"/usr/local/share/plasma/desktoptheme/breeze-dark",
"/usr/share/plasma/desktoptheme/breeze-dark",
}
if len(got) != len(want) {
t.Fatalf("plasmaStyleDirs() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("plasmaStyleDirs()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestReadIniValue(t *testing.T) {
path := filepath.Join(t.TempDir(), kdeglobalsFile)
if err := os.WriteFile(path, []byte(kdeglobalsLight), 0o600); err != nil {
t.Fatal(err)
}
// The same key exists in two groups, so a group-blind reader would return
// whichever came first.
if v, ok := readIniValue(path, "[Colors:Window]", "BackgroundNormal"); !ok || v != "239,240,241" {
t.Fatalf("Window BackgroundNormal = %q ok=%v, want \"239,240,241\" true", v, ok)
}
if v, ok := readIniValue(path, "[Colors:Complementary]", "BackgroundNormal"); !ok || v != "42,46,50" {
t.Fatalf("Complementary BackgroundNormal = %q ok=%v, want \"42,46,50\" true", v, ok)
}
if _, ok := readIniValue(path, "[Colors:Window]", "ColorSchemeHash"); ok {
t.Fatal("a key from another group should not be found")
}
if _, ok := readIniValue(filepath.Join(t.TempDir(), "absent"), "[Theme]", "name"); ok {
t.Fatal("a missing file should not be found")
}
if _, ok := readIniValue("", "[Theme]", "name"); ok {
t.Fatal("an empty path should not be found")
}
}
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(32, 35, 38) {
t.Fatal("BreezeDark window grey is dark")
}
if isDarkRGB(239, 240, 241) {
t.Fatal("Breeze window grey is light")
}
}