diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go new file mode 100644 index 000000000..a8ac02d3c --- /dev/null +++ b/client/ui/autostart_default.go @@ -0,0 +1,121 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "os" + "path/filepath" + "runtime" + + log "github.com/sirupsen/logrus" + + "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 +} + +// shouldEnableAutostartDefault applies the first-run guards in order and +// returns whether autostart may be enabled, plus the reason when it may not. +func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) { + switch { + case !s.supported: + 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" + } + return true, "" +} + +// autostartDisabledByMDM reports whether the MDM policy manages the +// disableAutostart key in a way that must suppress the default. An +// unparseable managed value is treated as disabled to stay on the safe side. +func autostartDisabledByMDM(policy *mdm.Policy) bool { + if !policy.HasKey(mdm.KeyDisableAutostart) { + return false + } + disabled, ok := policy.GetBool(mdm.KeyDisableAutostart) + 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) + } + return "" +} + +// 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 +// 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) { + if prefs.Get().AutostartInitialized { + return + } + if err := prefs.SetAutostartInitialized(true); err != nil { + log.Warnf("failed to 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), + } + enable, reason := shouldEnableAutostartDefault(state) + if !enable { + log.Debugf("skipping autostart default: %s", reason) + return + } + + if err := autostart.SetEnabled(ctx, true); err != nil { + log.Warnf("failed to 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. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/client/ui/autostart_default_test.go b/client/ui/autostart_default_test.go new file mode 100644 index 000000000..be84ac09e --- /dev/null +++ b/client/ui/autostart_default_test.go @@ -0,0 +1,132 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/mdm" +) + +func TestShouldEnableAutostartDefault(t *testing.T) { + allPass := autostartDefaultState{ + supported: true, + mdmDisabled: false, + postUpdateRelaunch: false, + breadcrumbPresent: true, + } + + tests := []struct { + name string + mutate func(*autostartDefaultState) + wantEnable bool + wantReason string + }{ + { + name: "fresh install with all guards passing enables", + mutate: func(*autostartDefaultState) {}, + wantEnable: true, + }, + { + name: "unsupported platform skips", + mutate: func(s *autostartDefaultState) { s.supported = false }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable skips", + mutate: func(s *autostartDefaultState) { s.mdmDisabled = true }, + wantReason: "autostart disabled by MDM policy", + }, + { + name: "post-update relaunch skips", + mutate: func(s *autostartDefaultState) { s.postUpdateRelaunch = true }, + wantReason: "post-update relaunch", + }, + { + name: "missing breadcrumb (upgrade or portable build) skips", + mutate: func(s *autostartDefaultState) { s.breadcrumbPresent = false }, + wantReason: "no fresh-install breadcrumb", + }, + { + name: "unsupported wins over every other guard", + mutate: func(s *autostartDefaultState) { + s.supported = false + s.mdmDisabled = true + s.postUpdateRelaunch = true + s.breadcrumbPresent = false + }, + wantReason: "autostart not supported on this platform", + }, + { + name: "breadcrumb present but relaunched after update skips", + mutate: func(s *autostartDefaultState) { + s.postUpdateRelaunch = true + s.breadcrumbPresent = true + }, + wantReason: "post-update relaunch", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + state := allPass + tc.mutate(&state) + enable, reason := shouldEnableAutostartDefault(state) + assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state) + assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard") + }) + } +} + +func TestAutostartDisabledByMDM(t *testing.T) { + tests := []struct { + name string + values map[string]any + want bool + }{ + { + name: "empty policy does not disable", + values: nil, + want: false, + }, + { + name: "unrelated managed keys do not disable", + values: map[string]any{mdm.KeyDisableAutoConnect: true}, + want: false, + }, + { + name: "disableAutostart true disables", + values: map[string]any{mdm.KeyDisableAutostart: true}, + want: true, + }, + { + name: "disableAutostart registry DWORD 1 disables", + values: map[string]any{mdm.KeyDisableAutostart: int64(1)}, + want: true, + }, + { + name: "disableAutostart string true disables", + values: map[string]any{mdm.KeyDisableAutostart: "true"}, + want: true, + }, + { + name: "disableAutostart explicit false allows", + values: map[string]any{mdm.KeyDisableAutostart: false}, + want: false, + }, + { + name: "unparseable managed value is treated as disabled", + values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"}, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := autostartDisabledByMDM(mdm.NewPolicy(tc.values)) + assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values) + }) + } +} diff --git a/client/ui/main.go b/client/ui/main.go index e6b77762c..083c0b41f 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -80,7 +80,7 @@ func init() { } func main() { - daemonAddr, userSetLogFile := parseFlagsAndInitLog() + daemonAddr, userSetLogFile, postUpdate := parseFlagsAndInitLog() conn := NewConn(daemonAddr) // Without --log-file, the GUI manages a gui-client.log that follows the @@ -197,6 +197,9 @@ func main() { // daemon may keep the main window from showing, so the OS toast is the // 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) }) if err := app.Run(); err != nil { @@ -221,17 +224,19 @@ func requestNotificationAuthorization(notifier *notifications.NotificationServic } } -// 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) { +// 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) { 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 @@ -243,7 +248,7 @@ func parseFlagsAndInitLog() (string, bool) { if err := util.InitLog(*logLevel, targets...); err != nil { log.Fatalf("init log: %v", err) } - return *daemonAddr, userSetLogFile + return *daemonAddr, userSetLogFile, *postUpdate } // newApplication constructs the Wails application. onSecondInstance fires when