mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-09 00:11:28 +02:00
[client] UI refactor (#6069)
Refactor UI --------- Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
267
client/ui/preferences/store.go
Normal file
267
client/ui/preferences/store.go
Normal file
@@ -0,0 +1,267 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
// Package preferences holds user-scope UI state, independent of the daemon
|
||||
// profile and shared across all profiles. The Store persists to JSON under
|
||||
// os.UserConfigDir() and broadcasts changes to in-process subscribers plus an
|
||||
// optional emitter.
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// Lives under os.UserConfigDir()/netbird (OS-user writable, not the daemon's
|
||||
// root-owned state).
|
||||
const preferencesFileName = "ui-preferences.json"
|
||||
|
||||
// EventPreferencesChanged fires on every persisted update, payload UIPreferences.
|
||||
const EventPreferencesChanged = "netbird:preferences:changed"
|
||||
|
||||
// ViewMode is the preferred Main-window layout: "default" (compact, 380-wide)
|
||||
// or "advanced" (900-wide).
|
||||
type ViewMode string
|
||||
|
||||
const (
|
||||
ViewModeDefault ViewMode = "default"
|
||||
ViewModeAdvanced ViewMode = "advanced"
|
||||
)
|
||||
|
||||
// DefaultViewMode applies when no file exists or its view-mode is empty.
|
||||
const DefaultViewMode = ViewModeDefault
|
||||
|
||||
var ErrUnsupportedViewMode = errors.New("unsupported view mode")
|
||||
|
||||
func (v ViewMode) IsValid() bool {
|
||||
switch v {
|
||||
case ViewModeDefault, ViewModeAdvanced:
|
||||
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"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
}
|
||||
|
||||
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
|
||||
// *i18n.Bundle satisfies it.
|
||||
type LanguageValidator interface {
|
||||
HasLanguage(code i18n.LanguageCode) bool
|
||||
}
|
||||
|
||||
// Emitter broadcasts changes to the frontend. Wails'
|
||||
// *application.EventProcessor satisfies it; tests pass nil or a fake.
|
||||
type Emitter interface {
|
||||
Emit(name string, data ...any) bool
|
||||
}
|
||||
|
||||
// Store is the user-scope UI preferences store.
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
current UIPreferences
|
||||
|
||||
subsMu sync.Mutex
|
||||
subs []chan UIPreferences
|
||||
|
||||
validator LanguageValidator
|
||||
emitter Emitter
|
||||
}
|
||||
|
||||
// NewStore loads preferences from disk, falling back to defaults. A nil
|
||||
// validator skips SetLanguage validation; a nil emitter skips broadcasting.
|
||||
func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
|
||||
path, err := preferencesPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve preferences path: %w", err)
|
||||
}
|
||||
|
||||
// Language starts empty: the frontend treats absence as the signal to
|
||||
// detect the browser locale on first launch and call SetLanguage.
|
||||
s := &Store{
|
||||
path: path,
|
||||
validator: validator,
|
||||
emitter: emitter,
|
||||
current: UIPreferences{ViewMode: DefaultViewMode},
|
||||
}
|
||||
|
||||
if err := s.load(); err != nil {
|
||||
log.Warnf("load ui preferences from %s: %v (using defaults)", path, err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get returns a copy of the current preferences.
|
||||
func (s *Store) Get() UIPreferences {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.current
|
||||
}
|
||||
|
||||
// SetViewMode validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetViewMode(mode ViewMode) error {
|
||||
if !mode.IsValid() {
|
||||
return fmt.Errorf("%w: %q", ErrUnsupportedViewMode, mode)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.current.ViewMode == mode {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.ViewMode = mode
|
||||
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()
|
||||
if s.current.OnboardingCompleted == done {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.OnboardingCompleted = done
|
||||
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
|
||||
}
|
||||
|
||||
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
||||
if lang == "" {
|
||||
return fmt.Errorf("%w: empty code", i18n.ErrUnsupportedLanguage)
|
||||
}
|
||||
if s.validator != nil && !s.validator.HasLanguage(lang) {
|
||||
return fmt.Errorf("%w: %q", i18n.ErrUnsupportedLanguage, lang)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.current.Language == lang {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.Language = lang
|
||||
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
|
||||
}
|
||||
|
||||
// Subscribe returns a channel of persisted changes and an unsubscribe func.
|
||||
// The unsubscribe func closes the channel; callers must not close it themselves.
|
||||
func (s *Store) Subscribe() (<-chan UIPreferences, func()) {
|
||||
ch := make(chan UIPreferences, 4)
|
||||
s.subsMu.Lock()
|
||||
s.subs = append(s.subs, ch)
|
||||
s.subsMu.Unlock()
|
||||
|
||||
unsubscribe := func() {
|
||||
s.subsMu.Lock()
|
||||
defer s.subsMu.Unlock()
|
||||
for i, c := range s.subs {
|
||||
if c == ch {
|
||||
s.subs = append(s.subs[:i], s.subs[i+1:]...)
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return ch, unsubscribe
|
||||
}
|
||||
|
||||
// load reads the file into current. A missing file is not an error (the
|
||||
// in-memory default stands); malformed contents return an error.
|
||||
func (s *Store) load() error {
|
||||
if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var loaded UIPreferences
|
||||
if _, err := util.ReadJson(s.path, &loaded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !loaded.ViewMode.IsValid() {
|
||||
loaded.ViewMode = DefaultViewMode
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.current = loaded
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistLocked writes v to disk. Caller must hold s.mu and update in-memory
|
||||
// state only after this returns nil.
|
||||
func (s *Store) persistLocked(v UIPreferences) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err)
|
||||
}
|
||||
return util.WriteJson(context.Background(), s.path, v)
|
||||
}
|
||||
|
||||
// broadcast fans v out to subscribers and the emitter. Full-buffer subscribers
|
||||
// are skipped: consumers only need the latest value, so dropping is safe.
|
||||
func (s *Store) broadcast(v UIPreferences) {
|
||||
s.subsMu.Lock()
|
||||
subs := make([]chan UIPreferences, len(s.subs))
|
||||
copy(subs, s.subs)
|
||||
s.subsMu.Unlock()
|
||||
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- v:
|
||||
default:
|
||||
log.Debugf("preferences subscriber channel full; dropping update")
|
||||
}
|
||||
}
|
||||
|
||||
if s.emitter != nil {
|
||||
s.emitter.Emit(EventPreferencesChanged, v)
|
||||
}
|
||||
}
|
||||
|
||||
func preferencesPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "netbird", preferencesFileName), nil
|
||||
}
|
||||
225
client/ui/preferences/store_test.go
Normal file
225
client/ui/preferences/store_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
)
|
||||
|
||||
// fakeValidator implements LanguageValidator for tests so we don't need a
|
||||
// fully-loaded i18n.Bundle.
|
||||
type fakeValidator struct{ ok map[i18n.LanguageCode]bool }
|
||||
|
||||
func (f fakeValidator) HasLanguage(code i18n.LanguageCode) bool { return f.ok[code] }
|
||||
|
||||
// recordingEmitter captures Emit calls so tests can assert the broadcast
|
||||
// fired.
|
||||
type recordingEmitter struct {
|
||||
mu sync.Mutex
|
||||
calls []emitCall
|
||||
}
|
||||
|
||||
type emitCall struct {
|
||||
name string
|
||||
data []any
|
||||
}
|
||||
|
||||
func (r *recordingEmitter) Emit(name string, data ...any) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, emitCall{name: name, data: data})
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *recordingEmitter) calledWith(name string) []emitCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []emitCall
|
||||
for _, c := range r.calls {
|
||||
if c.name == name {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withTempConfigDir reroots os.UserConfigDir() at a temporary directory by
|
||||
// pointing the OS-specific env vars there. Restored automatically by
|
||||
// t.Setenv.
|
||||
func withTempConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
t.Setenv("HOME", tmp)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(tmp, "Library", "Application Support"), 0o755))
|
||||
case "windows":
|
||||
t.Setenv("AppData", tmp)
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
func TestStore_DefaultsWhenFileMissing(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := s.Get()
|
||||
assert.Equal(t, i18n.LanguageCode(""), got.Language, "language must be empty when no file is on disk so the frontend can detect the browser locale")
|
||||
assert.Equal(t, DefaultViewMode, got.ViewMode, "view-mode default should still apply")
|
||||
}
|
||||
|
||||
func TestStore_SetLanguagePersistsAndBroadcasts(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
emitter := &recordingEmitter{}
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, emitter)
|
||||
require.NoError(t, err)
|
||||
|
||||
ch, unsubscribe := s.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
require.NoError(t, s.SetLanguage("hu"))
|
||||
|
||||
got := s.Get()
|
||||
assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should reflect the SetLanguage value")
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
assert.Equal(t, i18n.LanguageCode("hu"), v.Language, "subscriber should receive the new value")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscriber timed out waiting for update")
|
||||
}
|
||||
|
||||
emits := emitter.calledWith(EventPreferencesChanged)
|
||||
require.Len(t, emits, 1, "Emit should fire exactly once per SetLanguage")
|
||||
payload, ok := emits[0].data[0].(UIPreferences)
|
||||
require.True(t, ok, "emitter payload should be UIPreferences")
|
||||
assert.Equal(t, i18n.LanguageCode("hu"), payload.Language)
|
||||
}
|
||||
|
||||
func TestStore_LoadFromDisk(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
path, err := preferencesPath()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"language":"hu"}`), 0o644))
|
||||
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := s.Get()
|
||||
assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should load language from existing file")
|
||||
}
|
||||
|
||||
func TestStore_UnsupportedLanguageRejected(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.SetLanguage("xx")
|
||||
require.Error(t, err, "unknown language must be rejected")
|
||||
assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage)
|
||||
|
||||
err = s.SetLanguage("")
|
||||
assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage, "empty language code must be rejected")
|
||||
}
|
||||
|
||||
func TestStore_NoValidatorAcceptsAnything(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
s, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.SetLanguage("fr"))
|
||||
got := s.Get()
|
||||
assert.Equal(t, i18n.LanguageCode("fr"), got.Language)
|
||||
}
|
||||
|
||||
func TestStore_SetLanguageIdempotent(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
emitter := &recordingEmitter{}
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First call goes from "" (unset) to "en" — real change, one broadcast.
|
||||
require.NoError(t, s.SetLanguage("en"))
|
||||
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1,
|
||||
"first SetLanguage from unset should broadcast")
|
||||
|
||||
// Second call is a no-op — no disk write, no broadcast. Without this
|
||||
// guard the tray would re-render the menu on every cosmetic re-save of
|
||||
// the preferences file.
|
||||
require.NoError(t, s.SetLanguage("en"))
|
||||
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1,
|
||||
"re-setting the current language should not broadcast again")
|
||||
}
|
||||
|
||||
func TestStore_CorruptFileFallsBackToDefault(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
path, err := preferencesPath()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o644))
|
||||
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil)
|
||||
require.NoError(t, err, "corrupt file should not fail construction")
|
||||
|
||||
got := s.Get()
|
||||
assert.Equal(t, i18n.LanguageCode(""), got.Language, "corrupt JSON should leave the empty (unset) default in place so the frontend can re-detect")
|
||||
}
|
||||
|
||||
func TestStore_UnsubscribeStopsUpdates(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ch, unsubscribe := s.Subscribe()
|
||||
unsubscribe()
|
||||
|
||||
require.NoError(t, s.SetLanguage("hu"))
|
||||
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
assert.False(t, ok, "channel should be closed after unsubscribe")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected closed channel, got nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_FileShapeIsJSON(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.SetLanguage("hu"))
|
||||
|
||||
path, err := preferencesPath()
|
||||
require.NoError(t, err)
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed UIPreferences
|
||||
require.NoError(t, json.Unmarshal(data, &parsed), "on-disk file must be valid JSON")
|
||||
assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language)
|
||||
}
|
||||
|
||||
func TestStore_ErrUnsupportedSentinel(t *testing.T) {
|
||||
// Verifies callers can match on the sentinel error rather than parsing
|
||||
// strings — protects against accidental %v -> %w changes that would
|
||||
// silently break errors.Is.
|
||||
err := errors.New("inner")
|
||||
wrapped := errors.Join(i18n.ErrUnsupportedLanguage, err)
|
||||
assert.ErrorIs(t, wrapped, i18n.ErrUnsupportedLanguage)
|
||||
}
|
||||
Reference in New Issue
Block a user