[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.
This commit is contained in:
mlsmaycon
2026-07-12 12:42:12 +02:00
parent f601325aec
commit 416ecd90ec
3 changed files with 267 additions and 9 deletions

View File

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

View File

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

View File

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