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.
This commit is contained in:
mlsmaycon
2026-07-12 14:23:04 +02:00
parent 7a108fd572
commit 424671fb80
3 changed files with 64 additions and 63 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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