From 424671fb803bb7a8b12178db57466b71750593df Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 12 Jul 2026 14:23:04 +0200 Subject: [PATCH] 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. --- client/ui/autostart_default.go | 78 ++++++++++++++-------------------- client/ui/main.go | 24 +++++------ client/ui/preferences/store.go | 25 +++++++++-- 3 files changed, 64 insertions(+), 63 deletions(-) diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go index a8ac02d3c..bf1b16a97 100644 --- a/client/ui/autostart_default.go +++ b/client/ui/autostart_default.go @@ -6,28 +6,21 @@ import ( "context" "os" "path/filepath" - "runtime" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/ui/preferences" "github.com/netbirdio/netbird/client/ui/services" ) -// freshInstallBreadcrumbName is the marker file written by the platform -// installers on a fresh install only, never on an upgrade. Its presence is -// the update-safety gate for the autostart default: upgrading users never -// have it, so an update can never trigger a login-item write. -const freshInstallBreadcrumbName = ".fresh-install" - // autostartDefaultState carries the guard inputs of the one-time autostart // default decision so the decision itself stays a pure, testable function. type autostartDefaultState struct { - supported bool - mdmDisabled bool - postUpdateRelaunch bool - breadcrumbPresent bool + supported bool + mdmDisabled bool + priorInstall bool } // shouldEnableAutostartDefault applies the first-run guards in order and @@ -38,10 +31,8 @@ func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) { return false, "autostart not supported on this platform" case s.mdmDisabled: return false, "autostart disabled by MDM policy" - case s.postUpdateRelaunch: - return false, "post-update relaunch" - case !s.breadcrumbPresent: - return false, "no fresh-install breadcrumb" + case s.priorInstall: + return false, "existing NetBird installation" } return true, "" } @@ -57,45 +48,44 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool { return !ok || disabled } -// freshInstallBreadcrumbPath returns the installer-written breadcrumb -// location for the current platform, or "" when there is none. -func freshInstallBreadcrumbPath() string { - switch runtime.GOOS { - case "windows": - exe, err := os.Executable() - if err != nil { - log.Debugf("resolve executable path for fresh-install breadcrumb: %v", err) - return "" - } - return filepath.Join(filepath.Dir(exe), freshInstallBreadcrumbName) - case "darwin": - return filepath.Join("/Library/Application Support/NetBird", freshInstallBreadcrumbName) - case "linux": - return filepath.Join("/var/lib/netbird", freshInstallBreadcrumbName) +// netbirdFootprintExists reports whether the machine already carries NetBird +// daemon config or state, meaning this is not a genuinely fresh install. It is +// the update-safety gate for the autostart default: upgrading users always +// have a footprint, so an update can never trigger a login-item write. +func netbirdFootprintExists() bool { + candidates := []string{ + profilemanager.DefaultConfigPath, + filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"), + filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"), } - return "" + for _, path := range candidates { + if path != "" && fileExists(path) { + return true + } + } + return false } -// applyAutostartDefault runs the one-time launch-on-login default for fresh -// installs. The autostartInitialized marker is persisted before any enable -// attempt so a crash mid-flow degrades to "never enabled" instead of +// applyAutostartDefault runs the one-time launch-on-login default for genuinely +// fresh installs. The autostartInitialized marker is persisted before any +// enable attempt so a crash mid-flow degrades to "never enabled" instead of // retrying login-item writes on every launch. A user's later disable in // Settings is never overridden: the marker guarantees at-most-once, ever. -func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, postUpdateRelaunch bool) { +func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { + priorFootprint := netbirdFootprintExists() || prefsFileExisted + if prefs.Get().AutostartInitialized { return } if err := prefs.SetAutostartInitialized(true); err != nil { - log.Warnf("failed to persist autostart marker, skipping autostart default: %v", err) + log.Warnf("persist autostart marker, skipping autostart default: %v", err) return } - breadcrumb := freshInstallBreadcrumbPath() state := autostartDefaultState{ - supported: autostart.Supported(ctx), - mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()), - postUpdateRelaunch: postUpdateRelaunch, - breadcrumbPresent: breadcrumb != "" && fileExists(breadcrumb), + supported: autostart.Supported(ctx), + mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()), + priorInstall: priorFootprint, } enable, reason := shouldEnableAutostartDefault(state) if !enable { @@ -104,14 +94,10 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p } if err := autostart.SetEnabled(ctx, true); err != nil { - log.Warnf("failed to enable autostart on fresh install: %v", err) + log.Warnf("enable autostart on fresh install: %v", err) return } log.Info("autostart enabled by default on fresh install") - - if err := os.Remove(breadcrumb); err != nil { - log.Debugf("failed to remove fresh-install breadcrumb %s: %v", breadcrumb, err) - } } // fileExists reports whether path exists. diff --git a/client/ui/main.go b/client/ui/main.go index 083c0b41f..4889bad79 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -80,7 +80,7 @@ func init() { } func main() { - daemonAddr, userSetLogFile, postUpdate := parseFlagsAndInitLog() + daemonAddr, userSetLogFile := parseFlagsAndInitLog() conn := NewConn(daemonAddr) // Without --log-file, the GUI manages a gui-client.log that follows the @@ -198,8 +198,8 @@ func main() { // only reliable signal the user gets. go notifyIfDaemonOutdated(compat, notifier, localizer) // One-time launch-on-login default for fresh installs; gated by the - // installer breadcrumb, MDM policy, and the persisted marker. - go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, postUpdate) + // NetBird footprint check, MDM policy, and the persisted marker. + go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad()) }) if err := app.Run(); err != nil { @@ -224,19 +224,17 @@ func requestNotificationAuthorization(notifier *notifications.NotificationServic } } -// parseFlagsAndInitLog returns the daemon gRPC address, userSetLogFile -// (true when --log-file was passed), and postUpdate (true when the process -// was relaunched by an installer/updater via --post-update). userSetLogFile -// is the manual-override signal: true leaves logging alone, false lets the -// GUI manage a daemon-driven gui-client.log. The flag default is empty (not -// "console") so "no flag" and an explicit "--log-file console" stay -// distinguishable; empty falls back to console for InitLog. -func parseFlagsAndInitLog() (string, bool, bool) { +// parseFlagsAndInitLog returns the daemon gRPC address and userSetLogFile +// (true when --log-file was passed). userSetLogFile is the manual-override +// signal: true leaves logging alone, false lets the GUI manage a +// daemon-driven gui-client.log. The flag default is empty (not "console") so +// "no flag" and an explicit "--log-file console" stay distinguishable; empty +// falls back to console for InitLog. +func parseFlagsAndInitLog() (string, bool) { daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port") logFiles := &stringList{} flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console. Passing any value disables the daemon-debug-driven gui-client.log.") logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.") - postUpdate := flag.Bool("post-update", false, "Set by installer/updater relaunches; suppresses first-run defaults such as enabling autostart.") flag.Parse() userSetLogFile := len(logFiles.values) > 0 @@ -248,7 +246,7 @@ func parseFlagsAndInitLog() (string, bool, bool) { if err := util.InitLog(*logLevel, targets...); err != nil { log.Fatalf("init log: %v", err) } - return *daemonAddr, userSetLogFile, *postUpdate + return *daemonAddr, userSetLogFile } // newApplication constructs the Wails application. onSecondInstance fires when diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go index 2f81d7225..df6fbbb16 100644 --- a/client/ui/preferences/store.go +++ b/client/ui/preferences/store.go @@ -76,8 +76,9 @@ type Emitter interface { type Store struct { path string - mu sync.RWMutex - current UIPreferences + mu sync.RWMutex + current UIPreferences + existedAtLoad bool subsMu sync.Mutex subs []chan UIPreferences @@ -231,13 +232,29 @@ func (s *Store) Subscribe() (<-chan UIPreferences, func()) { 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); errors.Is(err, os.ErrNotExist) { - return nil + 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