mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-25 09:01:29 +02:00
Quitting the GUI from the tray always sent a Down RPC, dropping the VPN connection with it. Some users want the connection to survive the UI.
335 lines
8.7 KiB
Go
335 lines
8.7 KiB
Go
//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"`
|
|
// AutostartInitialized records that the one-time autostart default
|
|
// decision has run for this OS user. It only ever transitions to true
|
|
// and is never reset, so the default-on flow runs at most once, ever.
|
|
AutostartInitialized bool `json:"autostartInitialized"`
|
|
// KeepConnectedOnQuit leaves the daemon connected when the GUI quits.
|
|
// Its false zero value preserves the historical disconnect-on-quit
|
|
// behaviour for preference files written before the field existed.
|
|
KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"`
|
|
}
|
|
|
|
// 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
|
|
existedAtLoad bool
|
|
|
|
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
|
|
}
|
|
|
|
// SetAutostartInitialized persists the one-time autostart decision marker.
|
|
// No-op if unchanged.
|
|
func (s *Store) SetAutostartInitialized(done bool) error {
|
|
s.mu.Lock()
|
|
if s.current.AutostartInitialized == done {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
next := s.current
|
|
next.AutostartInitialized = 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
|
|
}
|
|
|
|
// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged.
|
|
func (s *Store) SetKeepConnectedOnQuit(keep bool) error {
|
|
s.mu.Lock()
|
|
if s.current.KeepConnectedOnQuit == keep {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
next := s.current
|
|
next.KeepConnectedOnQuit = keep
|
|
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
|
|
}
|
|
|
|
// ExistedAtLoad reports whether the backing preferences file was present on
|
|
// disk when the store loaded. It distinguishes a user who ran a prior GUI
|
|
// version from a brand-new OS user with no preferences yet.
|
|
func (s *Store) ExistedAtLoad() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.existedAtLoad
|
|
}
|
|
|
|
// 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); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
log.Infof("no ui preferences file at %s; using defaults", s.path)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("stat preferences: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.existedAtLoad = true
|
|
s.mu.Unlock()
|
|
|
|
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
|
|
}
|