Files
netbird/client/ui/services/theme.go
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

195 lines
7.5 KiB
Go

//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)
}
})
}