mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 21:59:56 +00:00
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.
222 lines
6.5 KiB
Go
222 lines
6.5 KiB
Go
//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
|
|
}
|