Files
netbird/client/ui/preferences/store.go
Riccardo Manfrin d29bc23bb7 [client] launch macOS GUI as the logged-in user after install/update (#6962)
## Describe your changes

In unattended installs/updates there may be no logged-in user, so
there's no context to start the GUI (nor anyone to see it).

The bug is the GUI being started in the wrong user context / inheriting
the wrong `$HOME` (OS mechanics aside).

Today the GUI is started by a per-user LaunchAgent, i.e. on behalf of
the user who logs in — no login ⇒ no GUI.

The patch aligns to this: it launches the GUI on behalf of the logged-in
console user if one exists, otherwise it delegates the launch to the
per-user LaunchAgent at next login.

Additionally it logs when default UI settings are applied.

Note (small caveat): the LaunchAgent auto-starts the GUI at login only
once it's been registered — which happens on the first GUI launch in the
user's context. On an MDM/unattended fresh install done with no user
logged in (where the user has never run the GUI before), they may need
to start it manually once; it self-registers from then on.

## Issue ticket number and link

No public issue — reported internally (community report on Slack: macOS
advanced-view + onboarding reset on every update, esp. via MDM/Munki).
Buggy line on main:
dd2bdc0de3/release_files/darwin_pkg/postinstall (L33)

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Internal macOS installer / GUI-launch behavior. No public API, CLI, or
configuration change: the fix only changes the user context the desktop
GUI is launched in after a pkg install/update.

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

N/A

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6962"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787923973&installation_model_id=427504&pr_number=6962&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6962&signature=5226bbc7986fc60eb8b4ab77e98ac17a6534864763fc6407048bcbbb1550394b"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Improved macOS installation and updater UI launching to occur only
when an active, valid GUI console session is detected.
- Prevented UI launches during unattended/system, root, or
login-window-related installs.
- Ensured the app is launched in the correct console-user context, and
skips cleanly when username/UID resolution fails.

- **Improvements**
- Added clearer informational logging when the UI preferences file is
not found and default preferences are used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 16:28:10 +02:00

311 lines
8.0 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) {
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
}