[client/ui] Introduce localisation (i18n + preferences) feature packages

Adds a tray + React translation pipeline driven by a single JSON locale
tree (frontend/src/i18n/locales) embedded into the Go binary. The tray
re-renders on language switch via a Localizer that subscribes to the
preferences store.

Layout:
- client/ui/i18n: Bundle, LanguageCode, Language, errors, embedded-FS
  loader. Pure domain, no Wails/daemon deps.
- client/ui/preferences: Store + UIPreferences for user-scope UI state,
  persisted under os.UserConfigDir()/netbird/ui-preferences.json with
  atomic writes and a subscribe/broadcast channel.
- client/ui/services: thin Wails-binding facades (services.I18n,
  services.Preferences) so React sees ctx-first signatures.
- client/ui/localizer.go: tray bridge that owns the active language,
  exposes T()/StatusLabel() and re-paints the menu on prefs change.
- tray.go: every user-facing const replaced by translation keys via
  t.loc.T(...); menu rebuild + state replay on language switch.
- main.go: //go:embed all:frontend/src/i18n/locales, wires Bundle ->
  Store -> Localizer -> Wails facades in order.

Frontend API exposed via Wails bindings: I18n.Languages, I18n.Bundle,
Preferences.Get, Preferences.SetLanguage, plus the
netbird:preferences:changed event.

Includes regenerated Wails TS bindings (peers/profileswitcher/etc.
re-emitted as part of the build) and en/hu seed bundles.
This commit is contained in:
Zoltan Papp
2026-05-15 11:08:19 +02:00
parent c0b0eeb6ab
commit 17cae1a75c
18 changed files with 1449 additions and 108 deletions

View File

@@ -0,0 +1,221 @@
//go:build !android && !ios && !freebsd && !js
// Package preferences holds user-scope UI state that is independent of the
// daemon profile: language, and any future toggles the React UI exposes to
// the user. The Store reads from and writes to a JSON file under
// os.UserConfigDir(), validates input against an injected language
// validator (typically *i18n.Bundle), and broadcasts changes to in-process
// subscribers (tray) plus an optional Wails emitter (frontend).
//
// No Wails dependency — the emitter is consumed through a minimal
// interface so the package can be tested without spinning up Wails.
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"
)
// preferencesFileName is the JSON file holding user-scope UI preferences.
// Stored under os.UserConfigDir()/netbird so it lives in the OS-user's
// writable config dir, not the daemon's root-owned state. Per-OS-user,
// shared across all daemon profiles.
const preferencesFileName = "ui-preferences.json"
// EventPreferencesChanged fires whenever the on-disk preferences are
// updated (from any source). The payload is the fresh UIPreferences value.
// Wails registers this name in init() so the React frontend can subscribe.
const EventPreferencesChanged = "netbird:preferences:changed"
// UIPreferences is the user-scope UI state mirrored to disk and to the
// frontend. Pointer-free because the whole document is rewritten on every
// change — there are no per-field partial updates.
type UIPreferences struct {
Language i18n.LanguageCode `json:"language"`
}
// LanguageValidator is the dependency Store needs to reject SetLanguage
// inputs that have no shipped bundle. *i18n.Bundle satisfies it directly.
type LanguageValidator interface {
HasLanguage(code i18n.LanguageCode) bool
}
// Emitter is the dependency Store needs to broadcast changes to the
// frontend. *application.EventProcessor (Wails) 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. Read at app start,
// updated by the React settings page (via the Wails-bound facade), and
// observed by the tray which re-renders its menu in the new language.
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 (creating a default file when
// none exists). The validator is consulted on SetLanguage; pass nil to
// skip validation (used by the unit tests). The emitter is optional —
// when set, SetLanguage broadcasts EventPreferencesChanged.
func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
path, err := preferencesPath()
if err != nil {
return nil, fmt.Errorf("resolve preferences path: %w", err)
}
s := &Store{
path: path,
validator: validator,
emitter: emitter,
current: UIPreferences{Language: i18n.DefaultLanguage},
}
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
}
// SetLanguage validates and persists a new language preference, then
// broadcasts the change to internal subscribers (tray) and the emitter
// (frontend).
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 that receives every persisted change. The
// unsubscribe function closes the channel and removes it from the list;
// callers must not close the channel 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 on-disk file into current. A missing file is not an
// error (we keep the in-memory default); malformed contents are reported
// so the caller can log+continue with the default.
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.Language == "" {
loaded.Language = i18n.DefaultLanguage
}
s.mu.Lock()
s.current = loaded
s.mu.Unlock()
return nil
}
// persistLocked writes the candidate preferences atomically. Caller must
// hold s.mu (write lock); the lock is not released here so the in-memory
// state is updated only after a successful write.
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 the new value out to internal subscribers and to the
// frontend emitter. Subscribers with a full buffer are skipped — the tray
// only cares about the latest value, so dropping intermediate frames
// during a burst 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)
}
}
// preferencesPath resolves os.UserConfigDir()/netbird/ui-preferences.json.
func preferencesPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "netbird", preferencesFileName), nil
}

View File

@@ -0,0 +1,220 @@
//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.DefaultLanguage, got.Language, "default language should be served when no file is on disk")
}
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)
require.NoError(t, s.SetLanguage("en"))
// SetLanguage to the current value 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.
assert.Empty(t, emitter.calledWith(EventPreferencesChanged),
"setting the current language should not broadcast")
}
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.DefaultLanguage, got.Language, "corrupt JSON should leave the default in place")
}
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)
}