@@ -326,7 +329,9 @@ function DoneResult({
@@ -339,7 +344,7 @@ function DoneResult({
{result.uploadFailureReason
diff --git a/client/ui/frontend/tailwind.config.ts b/client/ui/frontend/tailwind.config.ts
index 93ff39eea..da41ee111 100644
--- a/client/ui/frontend/tailwind.config.ts
+++ b/client/ui/frontend/tailwind.config.ts
@@ -10,29 +10,32 @@ const config: Config = {
},
extend: {
colors: {
+ // nb-gray is theme-aware: channels come from CSS variables in
+ // globals.css (:root = light ramp, .dark = original dark ramp). The
+ // rgb(var(...) / ) form keeps opacity modifiers working.
"nb-gray": {
- DEFAULT: "#181A1D",
- 50: "#f4f6f7",
- 100: "#e4e7e9",
- 200: "#cbd2d6",
- 250: "#b7c0c6",
- 300: "#a3adb5",
- 350: "#8f9ca8",
- 400: "#7c8994",
- 500: "#616e79",
- 600: "#535d67",
- 700: "#474e57",
- 800: "#3f444b",
- 850: "#363b40",
- 900: "#2e3238",
- 910: "#2b2f33",
- 920: "#25282d",
- 925: "#1e2123",
- 930: "#25282c",
- 935: "#1f2124",
- 940: "#1c1e21",
- 950: "#181a1d",
- 960: "#16181b",
+ DEFAULT: "rgb(var(--nb-gray-DEFAULT) / )",
+ 50: "rgb(var(--nb-gray-50) / )",
+ 100: "rgb(var(--nb-gray-100) / )",
+ 200: "rgb(var(--nb-gray-200) / )",
+ 250: "rgb(var(--nb-gray-250) / )",
+ 300: "rgb(var(--nb-gray-300) / )",
+ 350: "rgb(var(--nb-gray-350) / )",
+ 400: "rgb(var(--nb-gray-400) / )",
+ 500: "rgb(var(--nb-gray-500) / )",
+ 600: "rgb(var(--nb-gray-600) / )",
+ 700: "rgb(var(--nb-gray-700) / )",
+ 800: "rgb(var(--nb-gray-800) / )",
+ 850: "rgb(var(--nb-gray-850) / )",
+ 900: "rgb(var(--nb-gray-900) / )",
+ 910: "rgb(var(--nb-gray-910) / )",
+ 920: "rgb(var(--nb-gray-920) / )",
+ 925: "rgb(var(--nb-gray-925) / )",
+ 930: "rgb(var(--nb-gray-930) / )",
+ 935: "rgb(var(--nb-gray-935) / )",
+ 940: "rgb(var(--nb-gray-940) / )",
+ 950: "rgb(var(--nb-gray-950) / )",
+ 960: "rgb(var(--nb-gray-960) / )",
},
gray: {
50: "#F9FAFB",
diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json
index 11e085927..c39584992 100644
--- a/client/ui/i18n/locales/de/common.json
+++ b/client/ui/i18n/locales/de/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "Keine Sprachen gefunden."
},
+ "settings.general.theme.label": {
+ "message": "Erscheinungsbild"
+ },
+ "settings.general.theme.help": {
+ "message": "Hell, Dunkel oder die Systemeinstellung verwenden."
+ },
+ "settings.general.theme.system": {
+ "message": "System"
+ },
+ "settings.general.theme.light": {
+ "message": "Hell"
+ },
+ "settings.general.theme.dark": {
+ "message": "Dunkel"
+ },
"settings.general.management.label": {
"message": "Management-Server"
},
diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json
index 36f00e4bd..e9ee26de4 100644
--- a/client/ui/i18n/locales/en/common.json
+++ b/client/ui/i18n/locales/en/common.json
@@ -759,6 +759,26 @@
"message": "No languages match.",
"description": "Shown when no languages match the search."
},
+ "settings.general.theme.label": {
+ "message": "Theme",
+ "description": "Label for the appearance/theme picker."
+ },
+ "settings.general.theme.help": {
+ "message": "Choose light or dark, or follow your system appearance.",
+ "description": "Helper text for the theme picker."
+ },
+ "settings.general.theme.system": {
+ "message": "System",
+ "description": "Theme option: follow the OS appearance."
+ },
+ "settings.general.theme.light": {
+ "message": "Light",
+ "description": "Theme option: light appearance."
+ },
+ "settings.general.theme.dark": {
+ "message": "Dark",
+ "description": "Theme option: dark appearance."
+ },
"settings.general.management.label": {
"message": "Management Server",
"description": "Label for the management-server selector."
diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json
index 41872d7a0..245b5aa5f 100644
--- a/client/ui/i18n/locales/es/common.json
+++ b/client/ui/i18n/locales/es/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "Ningún idioma coincide."
},
+ "settings.general.theme.label": {
+ "message": "Tema"
+ },
+ "settings.general.theme.help": {
+ "message": "Elija el tema claro u oscuro, o siga la apariencia del sistema."
+ },
+ "settings.general.theme.system": {
+ "message": "Sistema"
+ },
+ "settings.general.theme.light": {
+ "message": "Claro"
+ },
+ "settings.general.theme.dark": {
+ "message": "Oscuro"
+ },
"settings.general.management.label": {
"message": "Servidor de gestión"
},
diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json
index 920ef8343..6da66a643 100644
--- a/client/ui/i18n/locales/fr/common.json
+++ b/client/ui/i18n/locales/fr/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "Aucune langue ne correspond."
},
+ "settings.general.theme.label": {
+ "message": "Thème"
+ },
+ "settings.general.theme.help": {
+ "message": "Choisissez le thème clair ou sombre, ou suivez l'apparence du système."
+ },
+ "settings.general.theme.system": {
+ "message": "Système"
+ },
+ "settings.general.theme.light": {
+ "message": "Clair"
+ },
+ "settings.general.theme.dark": {
+ "message": "Sombre"
+ },
"settings.general.management.label": {
"message": "Serveur de gestion"
},
diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json
index 82996e3d3..1b4d2fb9d 100644
--- a/client/ui/i18n/locales/hu/common.json
+++ b/client/ui/i18n/locales/hu/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "Nincs találat."
},
+ "settings.general.theme.label": {
+ "message": "Téma"
+ },
+ "settings.general.theme.help": {
+ "message": "Válassza a világos vagy sötét témát, vagy kövesse a rendszer beállítását."
+ },
+ "settings.general.theme.system": {
+ "message": "Rendszer"
+ },
+ "settings.general.theme.light": {
+ "message": "Világos"
+ },
+ "settings.general.theme.dark": {
+ "message": "Sötét"
+ },
"settings.general.management.label": {
"message": "Felügyeleti szerver"
},
diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json
index b8166aa6e..4cee0f842 100644
--- a/client/ui/i18n/locales/it/common.json
+++ b/client/ui/i18n/locales/it/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "Nessuna lingua corrisponde."
},
+ "settings.general.theme.label": {
+ "message": "Tema"
+ },
+ "settings.general.theme.help": {
+ "message": "Scelga il tema chiaro o scuro, oppure segua l'aspetto del sistema."
+ },
+ "settings.general.theme.system": {
+ "message": "Sistema"
+ },
+ "settings.general.theme.light": {
+ "message": "Chiaro"
+ },
+ "settings.general.theme.dark": {
+ "message": "Scuro"
+ },
"settings.general.management.label": {
"message": "Server di gestione"
},
diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json
index 6ffe05e1c..4fc81d283 100644
--- a/client/ui/i18n/locales/ja/common.json
+++ b/client/ui/i18n/locales/ja/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "一致する言語がありません。"
},
+ "settings.general.theme.label": {
+ "message": "テーマ"
+ },
+ "settings.general.theme.help": {
+ "message": "ライト、ダーク、またはシステムの外観に従います。"
+ },
+ "settings.general.theme.system": {
+ "message": "システム"
+ },
+ "settings.general.theme.light": {
+ "message": "ライト"
+ },
+ "settings.general.theme.dark": {
+ "message": "ダーク"
+ },
"settings.general.management.label": {
"message": "管理サーバー"
},
diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json
index 123e7a042..cb4a542d0 100644
--- a/client/ui/i18n/locales/pt/common.json
+++ b/client/ui/i18n/locales/pt/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "Nenhum idioma corresponde."
},
+ "settings.general.theme.label": {
+ "message": "Tema"
+ },
+ "settings.general.theme.help": {
+ "message": "Escolha claro, escuro ou siga a aparência do sistema."
+ },
+ "settings.general.theme.system": {
+ "message": "Sistema"
+ },
+ "settings.general.theme.light": {
+ "message": "Claro"
+ },
+ "settings.general.theme.dark": {
+ "message": "Escuro"
+ },
"settings.general.management.label": {
"message": "Servidor de gerenciamento"
},
diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json
index 3881a3783..61ece03b8 100644
--- a/client/ui/i18n/locales/ru/common.json
+++ b/client/ui/i18n/locales/ru/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "Языки не найдены."
},
+ "settings.general.theme.label": {
+ "message": "Тема"
+ },
+ "settings.general.theme.help": {
+ "message": "Выберите светлую или тёмную тему либо следуйте системной."
+ },
+ "settings.general.theme.system": {
+ "message": "Системная"
+ },
+ "settings.general.theme.light": {
+ "message": "Светлая"
+ },
+ "settings.general.theme.dark": {
+ "message": "Тёмная"
+ },
"settings.general.management.label": {
"message": "Сервер управления"
},
diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json
index 4e3f24102..f8fe71562 100644
--- a/client/ui/i18n/locales/uk/common.json
+++ b/client/ui/i18n/locales/uk/common.json
@@ -569,6 +569,21 @@
"settings.general.language.empty": {
"message": "Не знайдено жодної мови."
},
+ "settings.general.theme.label": {
+ "message": "Тема"
+ },
+ "settings.general.theme.help": {
+ "message": "Виберіть світлу чи темну тему або використовуйте системні налаштування."
+ },
+ "settings.general.theme.system": {
+ "message": "Системна"
+ },
+ "settings.general.theme.light": {
+ "message": "Світла"
+ },
+ "settings.general.theme.dark": {
+ "message": "Темна"
+ },
"settings.general.management.label": {
"message": "Сервер керування"
},
@@ -764,7 +779,7 @@
"settings.troubleshooting.anonymize.help": {
"message": "Приховує IP-адреси, домени та інші конфіденційні дані."
},
- "settings.troubleshooting.anonymize.info": {
+ "settings.troubleshooting.anonymize.info": {
"message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації."
},
"settings.troubleshooting.anonymize.none": {
@@ -1370,7 +1385,7 @@
"settings.ssh.privilege.oneWayInverted": {
"message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:"
},
- "settings.ssh.privilege.authorizePending": {
+ "settings.ssh.privilege.authorizePending": {
"message": "Очікування авторизації…"
}
}
diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json
index b1ff3370d..126b11851 100644
--- a/client/ui/i18n/locales/zh-CN/common.json
+++ b/client/ui/i18n/locales/zh-CN/common.json
@@ -571,6 +571,21 @@
"settings.general.language.empty": {
"message": "没有匹配的语言。"
},
+ "settings.general.theme.label": {
+ "message": "主题"
+ },
+ "settings.general.theme.help": {
+ "message": "选择浅色、深色或跟随系统外观。"
+ },
+ "settings.general.theme.system": {
+ "message": "跟随系统"
+ },
+ "settings.general.theme.light": {
+ "message": "浅色"
+ },
+ "settings.general.theme.dark": {
+ "message": "深色"
+ },
"settings.general.management.label": {
"message": "管理服务器"
},
diff --git a/client/ui/main.go b/client/ui/main.go
index 5652efcf2..09a589506 100644
--- a/client/ui/main.go
+++ b/client/ui/main.go
@@ -77,6 +77,7 @@ func init() {
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
application.RegisterEvent[updater.State](updater.EventStateChanged)
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
+ application.RegisterEvent[services.SystemTheme](services.EventSystemThemeChanged)
}
func main() {
@@ -123,6 +124,9 @@ func main() {
bundle, prefStore, localizer := buildI18n(app)
+ // Before any window exists so creation-time backgrounds are already themed.
+ app.RegisterService(application.NewService(services.NewTheme(app, prefStore)))
+
// After bundle + prefStore: both are used to localise daemon errors.
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
connection := services.NewConnection(conn, bundle, prefStore)
@@ -354,6 +358,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
if prefStore.Get().ViewMode == preferences.ViewModeAdvanced {
initialWidth = 900
}
+ appearance := services.CurrentAppearance()
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "NetBird",
@@ -363,13 +368,13 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
// drop new windows top-left unless asked.
InitialPosition: application.WindowCentered,
Hidden: true,
- BackgroundColour: services.WindowBackgroundColour,
+ BackgroundColour: services.WindowBackgroundColour(appearance),
URL: startURL,
DisableResize: true,
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
- Mac: services.AppleMacOSAppearanceOptions(),
- Windows: services.MicrosoftWindowsAppearanceOptions(),
+ Mac: services.AppleMacOSAppearanceOptions(appearance),
+ Windows: services.MicrosoftWindowsAppearanceOptions(appearance),
Linux: application.LinuxWindow{
Icon: iconWindow,
},
diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go
index 3b677016f..9f3f9297e 100644
--- a/client/ui/preferences/store.go
+++ b/client/ui/preferences/store.go
@@ -49,10 +49,34 @@ func (v ViewMode) IsValid() bool {
return false
}
+// Theme is the preferred UI appearance: follow the OS ("system") or force
+// "light"/"dark".
+type Theme string
+
+const (
+ ThemeSystem Theme = "system"
+ ThemeLight Theme = "light"
+ ThemeDark Theme = "dark"
+)
+
+// DefaultTheme applies when no file exists or its theme is empty/unknown.
+const DefaultTheme = ThemeSystem
+
+var ErrUnsupportedTheme = errors.New("unsupported theme")
+
+func (t Theme) IsValid() bool {
+ switch t {
+ case ThemeSystem, ThemeLight, ThemeDark:
+ return true
+ }
+ return false
+}
+
// UIPreferences is rewritten in full on every change; there are no partial updates.
type UIPreferences struct {
Language i18n.LanguageCode `json:"language"`
ViewMode ViewMode `json:"viewMode"`
+ Theme Theme `json:"theme"`
OnboardingCompleted bool `json:"onboardingCompleted"`
// AutostartInitialized records that the one-time autostart default
// decision has run for this OS user. It only ever transitions to true
@@ -105,7 +129,7 @@ func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
path: path,
validator: validator,
emitter: emitter,
- current: UIPreferences{ViewMode: DefaultViewMode},
+ current: UIPreferences{ViewMode: DefaultViewMode, Theme: DefaultTheme},
}
if err := s.load(); err != nil {
@@ -146,6 +170,30 @@ func (s *Store) SetViewMode(mode ViewMode) error {
return nil
}
+// SetTheme validates, persists, and broadcasts. No-op if unchanged.
+func (s *Store) SetTheme(theme Theme) error {
+ if !theme.IsValid() {
+ return fmt.Errorf("%w: %q", ErrUnsupportedTheme, theme)
+ }
+
+ s.mu.Lock()
+ if s.current.Theme == theme {
+ s.mu.Unlock()
+ return nil
+ }
+ next := s.current
+ next.Theme = theme
+ if err := s.persistLocked(next); err != nil {
+ s.mu.Unlock()
+ return fmt.Errorf("persist preferences: %w", err)
+ }
+ s.current = next
+ s.mu.Unlock()
+
+ s.broadcast(next)
+ return nil
+}
+
// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged.
func (s *Store) SetOnboardingCompleted(done bool) error {
s.mu.Lock()
@@ -288,6 +336,9 @@ func (s *Store) load() error {
if !loaded.ViewMode.IsValid() {
loaded.ViewMode = DefaultViewMode
}
+ if !loaded.Theme.IsValid() {
+ loaded.Theme = DefaultTheme
+ }
s.mu.Lock()
s.current = loaded
diff --git a/client/ui/services/appappearance_linux.go b/client/ui/services/appappearance_linux.go
new file mode 100644
index 000000000..0cfc46d5c
--- /dev/null
+++ b/client/ui/services/appappearance_linux.go
@@ -0,0 +1,99 @@
+//go:build linux && cgo && !android && !ios
+
+package services
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// setAppAppearance points GTK at the light or dark variant of the current theme
+// so the decorations match the webview. Without it a forced Light theme keeps
+// dark decorations on a dark desktop, and the reverse.
+//
+// The theme name is switched, not just gtk-application-prefer-dark-theme:
+// desktops such as Ubuntu implement dark mode as a separate theme (Yaru-dark),
+// which that flag cannot lighten. The flag is still set for themes that do
+// carry both variants under one name. Both are per-process settings, so this
+// changes only our own decorations; GTK re-reads the desktop value on a change,
+// which is why Theme.apply re-asserts. Must run on the main thread.
+//
+// GTK styling is app-wide, which is why this is separate from
+// setWindowAppearance: it must be applied even when no window exists yet, since
+// windows created later inherit it rather than carrying it in their options.
+func setAppAppearance(dark bool) {
+ target := baseGtkTheme(gtkThemeName())
+ if dark {
+ if variant, ok := darkGtkVariant(target); ok {
+ target = variant
+ }
+ }
+ // An unknown name would leave GTK with no theme at all, so fall back to
+ // changing nothing and let the prefer-dark flag do what it can.
+ if target != "" && !gtkThemeExists(target) {
+ target = ""
+ }
+ applyGtkTheme(target, dark)
+}
+
+// baseGtkTheme strips a dark-variant suffix, so "Yaru-dark" becomes "Yaru".
+func baseGtkTheme(name string) string {
+ for _, suffix := range []string{"-dark", "-Dark"} {
+ if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
+ return name[:len(name)-len(suffix)]
+ }
+ }
+ return name
+}
+
+// darkGtkVariant reports the installed dark counterpart of a base theme name.
+// Themes that carry both variants under one name have none, and rely on
+// gtk-application-prefer-dark-theme instead.
+func darkGtkVariant(base string) (string, bool) {
+ if base == "" {
+ return "", false
+ }
+ for _, suffix := range []string{"-dark", "-Dark"} {
+ if candidate := base + suffix; gtkThemeExists(candidate) {
+ return candidate, true
+ }
+ }
+ return "", false
+}
+
+// gtkThemeExists reports whether a theme of that name is installed, searching
+// the same locations GTK does.
+func gtkThemeExists(name string) bool {
+ if name == "" {
+ return false
+ }
+ for _, dir := range gtkThemeDirs() {
+ if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
+ return true
+ }
+ }
+ return false
+}
+
+func gtkThemeDirs() []string {
+ var dirs []string
+ if home, err := os.UserHomeDir(); err == nil {
+ dirs = append(dirs, filepath.Join(home, ".themes"))
+ }
+ if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
+ dirs = append(dirs, filepath.Join(dataHome, "themes"))
+ } else if home, err := os.UserHomeDir(); err == nil {
+ dirs = append(dirs, filepath.Join(home, ".local", "share", "themes"))
+ }
+ dataDirs := os.Getenv("XDG_DATA_DIRS")
+ if dataDirs == "" {
+ dataDirs = "/usr/local/share:/usr/share"
+ }
+ for _, dir := range strings.Split(dataDirs, ":") {
+ if dir != "" {
+ dirs = append(dirs, filepath.Join(dir, "themes"))
+ }
+ }
+ return dirs
+}
diff --git a/client/ui/services/appappearance_linux_gtk.go b/client/ui/services/appappearance_linux_gtk.go
new file mode 100644
index 000000000..2648b2808
--- /dev/null
+++ b/client/ui/services/appappearance_linux_gtk.go
@@ -0,0 +1,63 @@
+//go:build linux && cgo && !android && !ios
+
+package services
+
+/*
+// The GTK major version is the only difference between the two Linux builds, so
+// it is selected by these two directives rather than by keeping a second copy of
+// this file per version: the C below and the Go wrappers under it are identical
+// for GTK3 and GTK4, and both resolve through pkg-config.
+#cgo gtk3 pkg-config: gtk+-3.0
+#cgo !gtk3 pkg-config: gtk4
+#include
+#include
+
+static char *nbGetGtkThemeName(void) {
+ GtkSettings *settings = gtk_settings_get_default();
+ if (settings == NULL) {
+ return NULL;
+ }
+ char *name = NULL;
+ g_object_get(settings, "gtk-theme-name", &name, NULL);
+ return name;
+}
+
+// name may be NULL to leave the theme name untouched.
+static void nbSetGtkTheme(const char *name, int dark) {
+ GtkSettings *settings = gtk_settings_get_default();
+ if (settings == NULL) {
+ return;
+ }
+ if (name != NULL && name[0] != '\0') {
+ g_object_set(settings, "gtk-theme-name", name, NULL);
+ }
+ g_object_set(settings, "gtk-application-prefer-dark-theme", dark ? TRUE : FALSE, NULL);
+}
+
+static void nbFreeGtkString(char *s) { g_free(s); }
+*/
+import "C"
+
+import "unsafe"
+
+func gtkThemeName() string {
+ c := C.nbGetGtkThemeName()
+ if c == nil {
+ return ""
+ }
+ defer C.nbFreeGtkString(c)
+ return C.GoString(c)
+}
+
+func applyGtkTheme(name string, dark bool) {
+ var cName *C.char
+ if name != "" {
+ cName = C.CString(name)
+ defer C.free(unsafe.Pointer(cName))
+ }
+ var forced C.int
+ if dark {
+ forced = 1
+ }
+ C.nbSetGtkTheme(cName, forced)
+}
diff --git a/client/ui/services/appappearance_other.go b/client/ui/services/appappearance_other.go
new file mode 100644
index 000000000..aaad912b3
--- /dev/null
+++ b/client/ui/services/appappearance_other.go
@@ -0,0 +1,7 @@
+//go:build !(linux && cgo)
+
+package services
+
+// setAppAppearance is a no-op where the platform has no app-wide appearance to
+// set; macOS and Windows theme each window instead, via setWindowAppearance.
+func setAppAppearance(bool) {}
diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go
index 77faa4ef6..592d52e7b 100644
--- a/client/ui/services/preferences.go
+++ b/client/ui/services/preferences.go
@@ -31,6 +31,10 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
return s.store.SetViewMode(mode)
}
+func (s *Preferences) SetTheme(_ context.Context, theme preferences.Theme) error {
+ return s.store.SetTheme(theme)
+}
+
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
return s.store.SetOnboardingCompleted(done)
}
diff --git a/client/ui/services/theme.go b/client/ui/services/theme.go
new file mode 100644
index 000000000..52215e34b
--- /dev/null
+++ b/client/ui/services/theme.go
@@ -0,0 +1,194 @@
+//go:build !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/events"
+
+ "github.com/netbirdio/netbird/client/ui/preferences"
+)
+
+// EventSystemThemeChanged fires when the OS appearance flips, payload SystemTheme.
+// The frontend resolves the "system" preference against it.
+const EventSystemThemeChanged = "netbird:system-theme:changed"
+
+// SystemTheme is the EventSystemThemeChanged payload.
+type SystemTheme struct {
+ Dark bool `json:"dark"`
+}
+
+// Theme keeps native window background colours in step with the persisted
+// theme preference so no window flashes the wrong surface before the webview
+// paints. The frontend applies the matching .dark class via ThemeContext.
+type Theme struct {
+ app *application.App
+ store *preferences.Store
+ // mu serializes apply: concurrent callers could otherwise enqueue a stale
+ // pref's native updates after a newer one's.
+ mu sync.Mutex
+ // started gates the main-thread dispatch in apply: Run installs the platform
+ // layer InvokeAsync needs, and the store subscription can fire before that.
+ started atomic.Bool
+}
+
+// NewTheme wires the store subscription and OS theme-change listener. Call
+// before any window is created so creation-time colours are already themed.
+func NewTheme(app *application.App, store *preferences.Store) *Theme {
+ t := &Theme{app: app, store: store}
+ pref := store.Get().Theme
+ setAppearance(pref, resolveDark(pref, app.Env.IsDarkMode()))
+
+ // Window creation resolves through this rather than the seed above, which
+ // is wrong until Run installs the platform layer: Env.IsDarkMode reports
+ // light before that, so a "system" launch on a dark OS would build the
+ // first window light. The ApplicationStarted apply below cannot be relied
+ // on to land first because Wails runs each listener in its own goroutine.
+ // One store read backs both fields, so the snapshot is always self-consistent.
+ setAppearanceResolver(func() Appearance {
+ p := t.store.Get().Theme
+ return Appearance{Pref: p, Dark: resolveDark(p, t.app.Env.IsDarkMode())}
+ })
+
+ ch, _ := store.Subscribe()
+ go func() {
+ var last preferences.Theme
+ for p := range ch {
+ if p.Theme == last {
+ continue
+ }
+ last = p.Theme
+ t.apply()
+ }
+ }()
+
+ // Re-apply on every OS flip, not just for ThemeSystem: Windows re-evaluates
+ // process-level theme state on WM_SETTINGCHANGE, so a forced theme has to be
+ // re-asserted or the native chrome drifts to the OS appearance. The event's
+ // own IsDarkMode is deliberately unused: Wails runs each application event
+ // handler in its own goroutine, so two rapid flips race, and apply re-reads
+ // the appearance under mu instead.
+ app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(*application.ApplicationEvent) {
+ t.apply()
+ })
+
+ // Startup is split in two because Wails runs every application-event
+ // listener in its own goroutine, so a listener cannot be ordered against the
+ // one that opens the first-launch window. Hooks can: they run sequentially,
+ // in registration order, and all of them before any listener is spawned.
+ //
+ // The app-wide GTK theme goes in the hook because it is the part a window
+ // must not be created without. On Linux it draws the decorations and
+ // application.LinuxWindow carries no theme of its own, so a window built
+ // before it lands shows OS-coloured decorations until it does. It is applied
+ // synchronously for the same reason -- returning from the hook has to mean
+ // the theme is live. This relies on the listener below existing: Wails skips
+ // an event's hooks entirely when it has no listeners.
+ app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
+ t.started.Store(true)
+ t.syncAppAppearance()
+ })
+
+ // The rest of the startup apply. Env.IsDarkMode is a stub until the platform
+ // layer is up, so re-resolve once the app has started or a "system" launch on
+ // a light OS stays seeded dark.
+ app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
+ t.apply()
+ })
+
+ return t
+}
+
+// syncAppAppearance applies the app-wide appearance and waits for the UI thread
+// to have done it. Use it where a window is about to be created and must not be
+// built against the OS appearance: apply dispatches its own native work
+// asynchronously, so on Linux the GTK theme behind the decorations can otherwise
+// land after the window exists.
+//
+// No-op before the app has started, where InvokeSync has no platform layer to
+// dispatch to. Reads the appearance under mu like apply, so the two cannot
+// interleave into a torn update.
+func (t *Theme) syncAppAppearance() {
+ if !t.started.Load() {
+ return
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ pref := t.store.Get().Theme
+ dark := resolveDark(pref, t.app.Env.IsDarkMode())
+ setAppearance(pref, dark)
+ application.InvokeSync(func() { setAppAppearance(dark) })
+}
+
+// SystemDarkMode reports the OS appearance; bound so the frontend can resolve
+// the "system" preference from the same source as the native layer.
+func (t *Theme) SystemDarkMode(_ context.Context) (bool, error) {
+ return t.app.Env.IsDarkMode(), nil
+}
+
+// resolveDark maps a preference to an effective appearance against a system
+// reading the caller already took.
+func resolveDark(pref preferences.Theme, systemDark bool) bool {
+ switch pref {
+ case preferences.ThemeDark:
+ return true
+ case preferences.ThemeLight:
+ return false
+ default:
+ return systemDark
+ }
+}
+
+// apply recomputes the effective appearance, re-tints every live window
+// (including the macOS NSWindow appearance so the frame matches the webview)
+// and publishes the system appearance the frontend resolves "system" against.
+//
+// Everything runs under mu and reads the appearance here rather than taking it
+// from a caller, so a later apply always carries the fresher state and the
+// frontend event is ordered by the same lock as the native assignments. Emit
+// only appends to a FIFO mailbox, so holding mu across it cannot block.
+//
+// The OS is read exactly once per update and the resolved value is passed on to
+// the background and the native chrome, so those cannot land on either side of
+// an OS flip that happens mid-apply. The event carries the raw system reading,
+// not the resolved one, because the frontend resolves "system" itself.
+func (t *Theme) apply() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ pref := t.store.Get().Theme
+ systemDark := t.app.Env.IsDarkMode()
+ dark := resolveDark(pref, systemDark)
+ setAppearance(pref, dark)
+ t.app.Event.Emit(EventSystemThemeChanged, SystemTheme{Dark: systemDark})
+
+ // Before Run there is no platform layer for InvokeAsync to dispatch to.
+ // Windows created later read the globals set above.
+ if !t.started.Load() {
+ return
+ }
+
+ colour := windowBackgroundColour(dark)
+ // Re-tint on the UI thread and resolve each native handle there. Window
+ // teardown (markAsDestroyed then impl.close) runs as UI-thread work too, so
+ // a window closed meanwhile is either gone from GetAll or yields a nil
+ // handle -- never a freed handle the OS may already have reused.
+ application.InvokeAsync(func() {
+ // App-wide first, and unconditionally: on Linux this is the GTK theme
+ // that draws the decorations, and it must be set even with no window
+ // open because later windows inherit it instead of carrying it.
+ setAppAppearance(dark)
+ for _, w := range t.app.Window.GetAll() {
+ if w == nil {
+ continue
+ }
+ w.SetBackgroundColour(colour)
+ setWindowAppearance(w.NativeWindow(), pref, dark)
+ }
+ })
+}
diff --git a/client/ui/services/windowappearance_darwin.go b/client/ui/services/windowappearance_darwin.go
new file mode 100644
index 000000000..3c0a28447
--- /dev/null
+++ b/client/ui/services/windowappearance_darwin.go
@@ -0,0 +1,53 @@
+package services
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework AppKit
+
+#import
+
+// forced < 0: follow the OS (appearance nil); 0: light; 1: dark.
+//
+// Assigns directly rather than dispatching: Theme.apply already runs this on
+// the main thread. Deferring would outlive the caller's check that the window
+// is alive, and the __bridge cast does not retain it, so the block could touch
+// a freed NSWindow.
+static void nbSetWindowAppearance(void *nsWindow, int forced) {
+ NSWindow *window = (__bridge NSWindow *)nsWindow;
+ if (forced < 0) {
+ window.appearance = nil;
+ } else {
+ NSAppearanceName name = forced == 1 ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua;
+ window.appearance = [NSAppearance appearanceNamed:name];
+ }
+}
+*/
+import "C"
+
+import (
+ "unsafe"
+
+ "github.com/netbirdio/netbird/client/ui/preferences"
+)
+
+// setWindowAppearance pins the NSWindow appearance to the forced theme, or
+// hands it back to the OS for ThemeSystem. Without this, a window created
+// under one OS appearance keeps its dark/light frame after a manual theme
+// flip, leaving a mismatched border around the webview. Must run on the main
+// thread, which Theme.apply guarantees.
+//
+// The resolved appearance is unused: for ThemeSystem a nil NSAppearance lets
+// AppKit track the OS itself, which cannot drift from a snapshot we took.
+func setWindowAppearance(nsWindow unsafe.Pointer, pref preferences.Theme, _ bool) {
+ if nsWindow == nil {
+ return
+ }
+ forced := C.int(-1)
+ switch pref {
+ case preferences.ThemeLight:
+ forced = 0
+ case preferences.ThemeDark:
+ forced = 1
+ }
+ C.nbSetWindowAppearance(nsWindow, forced)
+}
diff --git a/client/ui/services/windowappearance_other.go b/client/ui/services/windowappearance_other.go
new file mode 100644
index 000000000..c2e9fbf74
--- /dev/null
+++ b/client/ui/services/windowappearance_other.go
@@ -0,0 +1,16 @@
+//go:build !darwin && !windows && !android && !ios && !freebsd && !js
+
+package services
+
+import (
+ "unsafe"
+
+ "github.com/netbirdio/netbird/client/ui/preferences"
+)
+
+// setWindowAppearance is a no-op wherever there is no per-window appearance to
+// set, which is every target this file covers. On Linux the appearance is real
+// but app-wide, so setAppAppearance owns it instead; on the remaining Unix
+// targets there is no native theming to apply at all and setAppAppearance is
+// itself a stub (appappearance_other.go).
+func setWindowAppearance(unsafe.Pointer, preferences.Theme, bool) {}
diff --git a/client/ui/services/windowappearance_windows.go b/client/ui/services/windowappearance_windows.go
new file mode 100644
index 000000000..8d5c88e47
--- /dev/null
+++ b/client/ui/services/windowappearance_windows.go
@@ -0,0 +1,54 @@
+package services
+
+import (
+ "unsafe"
+
+ "github.com/wailsapp/wails/v3/pkg/w32"
+
+ "github.com/netbirdio/netbird/client/ui/preferences"
+)
+
+// setWindowAppearance re-themes a live window's chrome; Wails only does this
+// itself on OS flips for SystemDefault windows.
+//
+// Must run on the UI thread, which Theme.apply guarantees: the uxtheme and
+// repaint calls behind w32.SetTheme belong to the window's thread, and hwnd is
+// only known live while we hold that thread. Re-dispatching here would let the
+// window be destroyed first and hand these writes a reused handle.
+//
+// dark is the appearance Theme.apply already resolved. Re-reading the OS here
+// would let the chrome land on the other side of an OS flip from the window
+// background and the webview.
+func setWindowAppearance(hwnd unsafe.Pointer, _ preferences.Theme, dark bool) {
+ if hwnd == nil || !w32.SupportsThemes() || w32.IsCurrentlyHighContrastMode() {
+ return
+ }
+
+ h := uintptr(hwnd)
+ w32.SetTheme(h, dark)
+
+ // After SetTheme, not before: its menu helper regates dark on the
+ // process-level ShouldAppsUseDarkMode and rewrites the per-window opt-in
+ // with that gated value, so forcing Dark on a light OS would lose it --
+ // and builds below 18985 need the opt-in for the pre-20H1 dark frame. The
+ // gated menu theme name is left alone on purpose: these windows carry no
+ // native menu, and popup-menu text follows the process policy, so forcing
+ // it dark gives dark text on dark.
+ if w32.AllowDarkModeForWindow != nil {
+ w32.AllowDarkModeForWindow(h, dark)
+ }
+
+ chrome := microsoftWindowsLightTheme
+ if dark {
+ chrome = microsoftWindowsDarkTheme
+ }
+ if chrome.TitleBarColour != nil {
+ w32.SetTitleBarColour(h, *chrome.TitleBarColour)
+ }
+ if chrome.TitleTextColour != nil {
+ w32.SetTitleTextColour(h, *chrome.TitleTextColour)
+ }
+ if chrome.BorderColour != nil {
+ w32.SetBorderColour(h, *chrome.BorderColour)
+ }
+}
diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go
index 94dba6038..1ba2ffdc0 100644
--- a/client/ui/services/windowmanager.go
+++ b/client/ui/services/windowmanager.go
@@ -6,6 +6,7 @@ import (
"net/url"
"strconv"
"sync"
+ "sync/atomic"
"time"
log "github.com/sirupsen/logrus"
@@ -36,39 +37,123 @@ const paintedFallback = 2 * time.Second
const headlessTeardownDelay = 2 * time.Second
-var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
+// Window background per effective appearance. Both match the body background
+// (bg-nb-gray DEFAULT) in globals.css so opaque native pixels and the webview
+// paint the same surface; keep the three in sync.
+var (
+ windowBackgroundDark = application.NewRGB(24, 26, 29) // dark nb-gray DEFAULT
+ windowBackgroundLight = application.NewRGB(243, 243, 243) // light nb-gray DEFAULT
+)
+
+// Appearance is one view of the theme state: the preference and the appearance
+// it resolves to. Take it once per window with CurrentAppearance and pass the
+// same value to every option builder -- Pref drives the macOS frame while Dark
+// drives the background and the Windows chrome, so reading them separately can
+// build a window with a new background behind the previous native frame.
+type Appearance struct {
+ Pref preferences.Theme
+ Dark bool
+}
+
+// storedAppearance is the snapshot maintained by services.Theme, published as
+// one value so the pair can never tear. It is the fallback for window creation
+// until resolveAppearance is installed.
+var storedAppearance atomic.Value // Appearance
+
+// resolveAppearance re-resolves against the live OS state. Theme installs it so
+// window creation never reads a stale seed: app.Env.IsDarkMode reports light
+// until Run installs the platform layer, and Wails runs every
+// ApplicationStarted listener in its own goroutine, so a startup window can be
+// created before Theme's listener has corrected the seed.
+var resolveAppearance atomic.Value // func() Appearance
+
+func init() {
+ storedAppearance.Store(Appearance{Pref: preferences.DefaultTheme, Dark: true})
+}
+
+func setAppearance(pref preferences.Theme, dark bool) {
+ storedAppearance.Store(Appearance{Pref: pref, Dark: dark})
+}
+
+func setAppearanceResolver(f func() Appearance) { resolveAppearance.Store(f) }
+
+// CurrentAppearance returns the snapshot every window creation must build from.
+func CurrentAppearance() Appearance {
+ if f, _ := resolveAppearance.Load().(func() Appearance); f != nil {
+ return f()
+ }
+ a, _ := storedAppearance.Load().(Appearance)
+ return a
+}
+
+// WindowBackgroundColour returns the background for a snapshot; use it for
+// every WebviewWindowOptions.BackgroundColour.
+func WindowBackgroundColour(a Appearance) application.RGBA {
+ return windowBackgroundColour(a.Dark)
+}
+
+// windowBackgroundColour maps a resolved appearance to its window background.
+func windowBackgroundColour(dark bool) application.RGBA {
+ if dark {
+ return windowBackgroundDark
+ }
+ return windowBackgroundLight
+}
// WindowHeight is shared by the main and Settings windows.
const WindowHeight = 660
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
-var microsoftWindowsTheme = &application.WindowTheme{
- BorderColour: u32ptr(0x00211E1C),
+var microsoftWindowsDarkTheme = &application.WindowTheme{
+ BorderColour: u32ptr(0x00211E1C), // #1C1E21 nb-gray-940
TitleBarColour: u32ptr(0x00211E1C),
- TitleTextColour: u32ptr(0x00E9E7E4),
+ TitleTextColour: u32ptr(0x00E9E7E4), // #E4E7E9 nb-gray-100
}
-// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar).
-func MicrosoftWindowsAppearanceOptions() application.WindowsWindow {
+var microsoftWindowsLightTheme = &application.WindowTheme{
+ BorderColour: u32ptr(0x00F3F3F3), // #F3F3F3 light nb-gray DEFAULT
+ TitleBarColour: u32ptr(0x00F3F3F3),
+ TitleTextColour: u32ptr(0x00212121), // #212121 light nb-gray-100
+}
+
+// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica +
+// custom title bar), resolved at creation; setWindowAppearance re-themes live
+// windows on later changes. Never SystemDefault: Wails gives those windows a
+// SystemThemeChanged handler that re-themes chrome from the OS appearance,
+// which outlives a switch to a forced theme and fights it on the next OS flip.
+// Both CustomTheme slots hold one colour set for the same reason.
+func MicrosoftWindowsAppearanceOptions(a Appearance) application.WindowsWindow {
+ theme, chrome := application.Light, microsoftWindowsLightTheme
+ if a.Dark {
+ theme, chrome = application.Dark, microsoftWindowsDarkTheme
+ }
return application.WindowsWindow{
BackdropType: application.Mica,
- Theme: application.Dark,
+ Theme: theme,
CustomTheme: application.ThemeSettings{
- DarkModeActive: microsoftWindowsTheme,
- DarkModeInactive: microsoftWindowsTheme,
- LightModeActive: microsoftWindowsTheme,
- LightModeInactive: microsoftWindowsTheme,
+ DarkModeActive: chrome,
+ DarkModeInactive: chrome,
+ LightModeActive: chrome,
+ LightModeInactive: chrome,
},
}
}
// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout.
-func AppleMacOSAppearanceOptions() application.MacWindow {
+func AppleMacOSAppearanceOptions(a Appearance) application.MacWindow {
+ appearance := application.DefaultAppearance
+ switch a.Pref {
+ case preferences.ThemeLight:
+ appearance = application.NSAppearanceNameAqua
+ case preferences.ThemeDark:
+ appearance = application.NSAppearanceNameDarkAqua
+ }
return application.MacWindow{
InvisibleTitleBarHeight: 38,
Backdrop: application.MacBackdropNormal,
TitleBar: application.MacTitleBarHiddenInset,
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
+ Appearance: appearance,
}
}
@@ -82,6 +167,7 @@ func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog.
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
+ a := CurrentAppearance()
return application.WebviewWindowOptions{
Name: name,
Title: title,
@@ -93,10 +179,10 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
- BackgroundColour: WindowBackgroundColour,
+ BackgroundColour: WindowBackgroundColour(a),
URL: url,
- Mac: AppleMacOSAppearanceOptions(),
- Windows: MicrosoftWindowsAppearanceOptions(),
+ Mac: AppleMacOSAppearanceOptions(a),
+ Windows: MicrosoftWindowsAppearanceOptions(a),
Linux: LinuxAppearanceOptions(linuxIcon),
}
}
@@ -164,6 +250,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
}
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
+ a := CurrentAppearance()
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: s.title("window.title.settings"),
@@ -174,10 +261,10 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
- BackgroundColour: WindowBackgroundColour,
+ BackgroundColour: WindowBackgroundColour(a),
URL: "/#/settings",
- Mac: AppleMacOSAppearanceOptions(),
- Windows: MicrosoftWindowsAppearanceOptions(),
+ Mac: AppleMacOSAppearanceOptions(a),
+ Windows: MicrosoftWindowsAppearanceOptions(a),
Linux: LinuxAppearanceOptions(s.linuxIcon),
})
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go
index a3249e57a..f9978a7fe 100644
--- a/client/ui/tray_theme_linux.go
+++ b/client/ui/tray_theme_linux.go
@@ -17,6 +17,13 @@ import (
log "github.com/sirupsen/logrus"
)
+// The two KDE files that decide the panel's appearance. Both live in the user
+// config dir, so one directory watch covers them (see watchKdeConfig).
+const (
+ kdeglobalsFile = "kdeglobals"
+ plasmarcFile = "plasmarc"
+)
+
// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must
// run before the first applyIcon so the initial paint uses the right silhouette.
func (t *Tray) startTrayTheme() {
@@ -35,50 +42,142 @@ func isKDE() bool {
return false
}
-// kdeglobalsPath returns the user kdeglobals path. We read only this file, not
-// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a
-// missing Complementary group falls back to the portal.
-func kdeglobalsPath() string {
+// kdeConfigPath locates one of KDE's user config files. We read only the user
+// file, not the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme
+// and style there, and anything missing falls back to the portal.
+func kdeConfigPath(name string) string {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
- return filepath.Join(dir, "kdeglobals")
+ return filepath.Join(dir, name)
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
- return filepath.Join(home, ".config", "kdeglobals")
+ return filepath.Join(home, ".config", name)
}
-// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of
-// its "Complementary" background (the colour Plasma paints the tray with). ok
-// is false when this isn't KDE or the colour can't be read, so the caller falls
-// through to the portal/GTK path.
+func kdeglobalsPath() string { return kdeConfigPath(kdeglobalsFile) }
+func plasmarcPath() string { return kdeConfigPath(plasmarcFile) }
+
+// kdePanelIsDark reports whether the KDE Plasma panel the tray icon sits on is
+// dark. ok is false when this isn't KDE or neither source was conclusive, so the
+// caller falls through to the portal/GTK path.
func kdePanelIsDark() (dark, ok bool) {
if !isKDE() {
return false, false
}
- path := kdeglobalsPath()
- if path == "" {
- return false, false
+ // A pinned Plasma style paints the panel itself, so it outranks the
+ // application colour scheme: a style with dark colours under a Light scheme
+ // is still a dark panel and still needs the white silhouette.
+ if dark, ok := plasmaStyleIsDark(); ok {
+ return dark, true
}
- rgb, ok := readKdeComplementaryBackground(path)
+ // The default style follows the colour scheme, so decide by the luma of the
+ // window background Plasma derives the panel from. Deliberately not
+ // Complementary: that group is dark under Breeze *and* BreezeLight
+ // (42,46,50 measured on Plasma 6.7.4), so reading it kept the white icon on
+ // a light panel, where it is all but invisible.
+ rgb, ok := readKdeColour(kdeglobalsPath(), "[Colors:Window]")
if !ok {
return false, false
}
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
}
-// readKdeComplementaryBackground parses kdeglobals for
-// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255).
-func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
+// plasmaStyleIsDark reports the appearance the pinned Plasma style paints the
+// panel with, decided by the style's own colours rather than by its name. A
+// style that ships a colours file overrides the colour scheme for the shell, and
+// nothing requires the name to admit it: breeze-dark happens to, but a style
+// named neutrally can carry dark colours just as well (measured on Plasma 6.7.4:
+// panel luma 38 while kdeglobals and the portal both reported light).
+//
+// ok is false when no style is pinned, when the pinned style ships no colours --
+// the "default" style, which is exactly the case that follows the colour scheme
+// -- and when its colours cannot be read; all three fall through to kdeglobals.
+func plasmaStyleIsDark() (dark, ok bool) {
+ name, found := readIniValue(plasmarcPath(), "[Theme]", "name")
+ if !found || name == "" {
+ return false, false
+ }
+ if !isBareStyleName(name) {
+ log.Debugf("tray theme: ignoring plasma style name %q, not a bare directory name", name)
+ return false, false
+ }
+ for _, dir := range plasmaStyleDirs(name) {
+ rgb, found := readKdeColour(filepath.Join(dir, "colors"), "[Colors:Window]")
+ if !found {
+ continue
+ }
+ return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
+ }
+ return false, false
+}
+
+// isBareStyleName reports whether name is safe to index a directory with. The
+// name comes from a config file and is joined into a path, so it has to be a
+// single ordinary element: "../" in it would point the read anywhere, and ".",
+// ".." and "/" all survive filepath.Base(filepath.Clean(name)) unchanged, so
+// they need rejecting by name -- ".." alone resolves a level above desktoptheme.
+func isBareStyleName(name string) bool {
+ if name == "" || name == "." || name == ".." || filepath.IsAbs(name) {
+ return false
+ }
+ return name == filepath.Base(filepath.Clean(name))
+}
+
+// plasmaStyleDirs lists where a Plasma style of that name may live, in the order
+// Plasma itself resolves them: the user data dir first, so a local style shadows
+// a system one of the same name.
+func plasmaStyleDirs(name string) []string {
+ var dirs []string
+ for _, base := range xdgDataDirs() {
+ dirs = append(dirs, filepath.Join(base, "plasma", "desktoptheme", name))
+ }
+ return dirs
+}
+
+// xdgDataDirs returns XDG_DATA_HOME (or its default) followed by XDG_DATA_DIRS.
+func xdgDataDirs() []string {
+ var dirs []string
+ if home := os.Getenv("XDG_DATA_HOME"); home != "" {
+ dirs = append(dirs, home)
+ } else if h, err := os.UserHomeDir(); err == nil {
+ dirs = append(dirs, filepath.Join(h, ".local", "share"))
+ }
+ system := os.Getenv("XDG_DATA_DIRS")
+ if system == "" {
+ system = "/usr/local/share:/usr/share"
+ }
+ for _, dir := range strings.Split(system, ":") {
+ if dir != "" {
+ dirs = append(dirs, dir)
+ }
+ }
+ return dirs
+}
+
+// readKdeColour reads group's BackgroundNormal as an R,G,B triple.
+func readKdeColour(path, group string) (rgb [3]uint8, ok bool) {
+ val, found := readIniValue(path, group, "BackgroundNormal")
+ if !found {
+ return rgb, false
+ }
+ return parseRGB(val)
+}
+
+// readIniValue returns key's value inside group from a KDE-style INI file.
+// group carries its own brackets, e.g. "[Colors:Window]".
+func readIniValue(path, group, key string) (value string, ok bool) {
+ if path == "" {
+ return "", false
+ }
f, err := os.Open(path)
if err != nil {
- log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err)
- return rgb, false
+ log.Debugf("tray theme: %s open failed, using portal: %v", filepath.Base(path), err)
+ return "", false
}
defer func() { _ = f.Close() }()
- const group = "[Colors:Complementary]"
inGroup := false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
@@ -90,13 +189,13 @@ func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
if !inGroup {
continue
}
- key, val, found := strings.Cut(line, "=")
- if !found || strings.TrimSpace(key) != "BackgroundNormal" {
+ k, v, found := strings.Cut(line, "=")
+ if !found || strings.TrimSpace(k) != key {
continue
}
- return parseRGB(strings.TrimSpace(val))
+ return strings.TrimSpace(v), true
}
- return rgb, false
+ return "", false
}
// parseRGB parses KDE's "r,g,b" colour triple into bytes.
diff --git a/client/ui/tray_theme_linux_test.go b/client/ui/tray_theme_linux_test.go
index f14f08d7f..e3476055d 100644
--- a/client/ui/tray_theme_linux_test.go
+++ b/client/ui/tray_theme_linux_test.go
@@ -8,49 +8,346 @@ import (
"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]
+// 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
`
- path := filepath.Join(t.TempDir(), "kdeglobals")
- if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ 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
+}
- rgb, ok := readKdeComplementaryBackground(path)
- if !ok {
- t.Fatal("expected to find Complementary BackgroundNormal")
+// 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)
+ }
}
- 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")
+ 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)
+ }
+ })
}
}
-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 {
+// 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)
}
- if _, ok := readKdeComplementaryBackground(path); ok {
- t.Fatal("expected not-ok when Complementary group is absent")
+ // 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")
}
}
@@ -77,8 +374,8 @@ func TestIsDarkRGB(t *testing.T) {
if isDarkRGB(255, 255, 255) {
t.Fatal("white is light")
}
- if !isDarkRGB(42, 46, 50) {
- t.Fatal("Breeze panel grey is dark")
+ if !isDarkRGB(32, 35, 38) {
+ t.Fatal("BreezeDark window 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
index b9bafe30b..fa175e006 100644
--- a/client/ui/tray_theme_watcher_linux.go
+++ b/client/ui/tray_theme_watcher_linux.go
@@ -3,9 +3,10 @@
package main
// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE
-// the kdeglobals file (the portal's color-scheme doesn't track the panel's
-// Complementary colour — see readDarkMode). The dark/light decision lives in
-// tray_theme_linux.go; this file owns the session-bus connection and subscriptions.
+// 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"
@@ -66,9 +67,9 @@ func startThemeWatcher(onChange func()) *themeWatcher {
// Keep the connection: the seeded darkMode value is still useful.
}
- // The portal's signal doesn't track KDE's panel Complementary colour.
+ // The portal's signal says nothing about a pinned Plasma style.
if isKDE() {
- w.watchKdeglobals()
+ w.watchKdeConfig()
}
log.Infof("tray theme: panel dark mode = %v", w.IsDark())
@@ -88,12 +89,11 @@ func (w *themeWatcher) IsDark() bool {
// readDarkMode resolves whether the panel the tray icon sits on is dark.
//
-// On KDE the freedesktop color-scheme is the application preference, not the
-// panel's: Plasma paints its panel from the Breeze "Complementary" group, which
-// stays dark even under a Light global scheme, so we read the panel background
-// from kdeglobals first and decide by its luma. 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.
+// 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
@@ -160,8 +160,8 @@ func (w *themeWatcher) loop(sigs chan *dbus.Signal) {
continue
}
- // Re-resolve via readDarkMode, not the signal value: under KDE the panel
- // colour comes from kdeglobals, so the signal value would be wrong.
+ // 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()
}
}
@@ -179,22 +179,23 @@ func (w *themeWatcher) update() {
}
}
-// watchKdeglobals watches the parent directory, not the file: KDE rewrites
-// kdeglobals atomically (write-temp + rename), which would drop an inotify watch
-// on the original inode. Filtering by name re-arms implicitly.
-func (w *themeWatcher) watchKdeglobals() {
+// 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, name := filepath.Split(path)
+ dir := filepath.Dir(path)
fw, err := fsnotify.NewWatcher()
if err != nil {
- log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err)
+ log.Debugf("tray theme: KDE config watcher unavailable, theme is static: %v", err)
return
}
- if err := fw.Add(filepath.Clean(dir)); err != nil {
+ if err := fw.Add(dir); err != nil {
log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err)
_ = fw.Close()
return
@@ -208,10 +209,14 @@ func (w *themeWatcher) watchKdeglobals() {
if !ok {
return
}
- if filepath.Base(event.Name) != name {
+ switch filepath.Base(event.Name) {
+ case kdeglobalsFile, plasmarcFile:
+ default:
continue
}
- if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
+ // 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()
@@ -219,7 +224,7 @@ func (w *themeWatcher) watchKdeglobals() {
if !ok {
return
}
- log.Debugf("tray theme: kdeglobals watch error: %v", err)
+ log.Debugf("tray theme: KDE config watch error: %v", err)
}
}
}()
diff --git a/go.mod b/go.mod
index a2fe1e55b..8987803c1 100644
--- a/go.mod
+++ b/go.mod
@@ -343,6 +343,6 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
-replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78
+replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1
tool go.uber.org/mock/mockgen
diff --git a/go.sum b/go.sum
index 3e0b4f5dc..495b62eac 100644
--- a/go.sum
+++ b/go.sum
@@ -490,8 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
-github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 h1:B/jRv24jnFeoA+VccxoCx6K94PUgsqR9wnshpeu9M+8=
-github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
+github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 h1:n5aXV/U6I9bLc+yWN088TyVR4OfF64Gy+L6Hrffc+n4=
+github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=