Files
netbird/client/ui/preferences/store.go
Maycon Santos 8f64173574 [client] Enable launch-on-login by default on fresh GUI installs (#6738)
* [client] Add autostart preference marker and MDM disableAutostart key

Adds the autostartInitialized marker to the Wails UI preferences store so
the one-time autostart default decision can persist per OS user, and a
UI-only disableAutostart MDM policy key that suppresses the default and
flows into GetConfigResponse.mDMManagedFields like disableAutoConnect.

* [client] Enable launch-on-login by default on fresh GUI installs

On the first interactive run the GUI persists the autostartInitialized
marker before any enable attempt, then enables autostart only when the
platform supports it, MDM policy does not disable it, the process was not
relaunched by an installer/updater (--post-update), and the installer's
fresh-install breadcrumb is present. Upgrading users have no breadcrumb,
so an update can never write login items, and a user's disable in
Settings is never overridden.

* [release] Write fresh-install breadcrumb from installers

Installers write a .fresh-install breadcrumb on fresh installs only and
delete stale breadcrumbs on upgrade; none of them writes login items or
registry Run keys. Windows NSIS detects upgrades via the uninstall
registry entry or an existing installed executable; the macOS pkg via the
previous pkgutil receipt; Linux deb/rpm via the standard postinstall
arguments. Post-update GUI relaunches (macOS open, Linux
ui-post-install.sh) pass --post-update so the first-run autostart default
cannot fire on updates.

* Revert installer breadcrumb changes

The real Windows installer does uninstall-then-install and deletes
$INSTDIR, so a breadcrumb written there cannot survive or discriminate
a fresh install from an upgrade. Restore the three installer files to
their main versions; no installer or updater writes an autostart entry.

* Detect fresh install from NetBird footprint instead of installer breadcrumb

Replace the installer-written breadcrumb discriminator with a GUI-side
check. netbirdFootprintExists inspects the daemon config/state files
(default.json, legacy config.json, state.json) under profilemanager's
default config dir; combined with whether the UI preferences file already
existed, this tells a genuinely fresh machine from an existing or
upgrading user. Only the signed GUI, via Wails, ever enables
launch-on-login, and a user's later manual disable is never overridden.
The preferences store now exposes ExistedAtLoad and the --post-update
flag is dropped.

* Update tests for footprint-based autostart default

Table tests for shouldEnableAutostartDefault now cover supported,
mdmDisabled, and priorInstall guards plus precedence; breadcrumb and
post-update cases are removed. Add a store test asserting ExistedAtLoad
is false with no file and true after persisting and reopening.
2026-07-13 13:39:57 +02:00

310 lines
7.9 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"`
}
// 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
}
// 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) {
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
}